Analytics Engine - Design Specification¶
| Document ID | AH-DS-06 |
| Package | mineproductivity.analytics |
| Status | Draft - Design Complete, Pending Implementation |
| Version | 1.0.0 |
| Conforms to | Master Architecture Handbook v1.0; Reference Implementation Blueprint v1.0; Developer & Cookbook Guide Parts I-III |
| Builds on | Core Foundation Library v0.2.0 (LOCKED); Event Framework spec 01 (LOCKED, events v0.3.0); Ontology Framework spec 02 (LOCKED, ontology v0.4.0); Registry Framework spec 03 (LOCKED, registry/plugins v0.5.0); Connector Framework spec 04 (LOCKED, connectors v0.6.0); KPI Engine spec 05 (LOCKED, kpis v0.7.0) |
| Author | Chief Software Architect, MineProductivity |
| Classification | Public - Open Source Design Documentation |
Document Control¶
Design specification only - no implementation. This document designs mineproductivity.analytics, the first package built on top of the now-locked Foundation Layer (core, events, ontology, registry, plugins, connectors, kpis). Nothing in this specification proposes, requires, or hints at a change to any Foundation Layer file, public API, or dependency rule. Every object model, package name, and enum member cited from a Foundation Layer package is taken verbatim from that package's own __init__.py public export list or its own governing design specification - this document introduces zero synonyms for concepts those specifications already name. Section numbering (1-37) in this document is a domain-specific table of contents for the Analytics Engine and is deliberately different from the 37-section skeleton used by specifications 01-05, because Analytics' architecturally significant surface (pipelines, windowing, statistical primitives, benchmarking, streaming) does not map cleanly onto that skeleton; the surrounding conventions (header table, §N cross-references, Mermaid diagrams, ❌ anti-pattern bullets, a companion implementation checklist) are preserved exactly.
1. Purpose¶
The Analytics Engine answers the question the KPI Engine deliberately does not: given a number (or a series of numbers), what does it mean? kpis (spec 05) makes every metric a discoverable, versioned, self-describing object and guarantees that two engineers computing "availability" from the same events get the same number. analytics exists to take that guaranteed-correct number - or a whole series of them, across shifts, sites, or years - and answer the next layer of operational questions a single KPIResult cannot answer by itself: is this trending up or down? Is this normal, or an outlier? How does it compare to the published benchmark bands? What is the 7-day rolling picture? How confident are we, given how much data we have?
Analytics is the platform's statistical and analytical computation layer: it consumes already-correct inputs (raw event rows via events.EventStore, and already-computed kpis.KPIResult objects) and produces analytical judgments about them - trends, benchmarks, baselines, distributions, confidence intervals, data-quality scores - as new, equally discoverable, equally versioned result objects. It holds no ingestion logic, no event or ontology definitions, no plugin-loading machinery, and no KPI formulas of its own; all of those already exist, one layer down, and are consumed rather than re-implemented (§3, §9).
2. Business Objectives¶
- Turn a correct number into an operational judgment. A
KPIResultof1,212.1 t/his correct but silent on whether that is good, improving, or expected. Analytics exists to close that gap without asking every dashboard, report, or AI agent to reimplement its own ad hoc statistics. - Make cross-site, cross-fleet, and cross-period comparison a platform capability, not a spreadsheet exercise. Benchmarking and trend analysis are implemented once, correctly, against the KPI Engine's own metadata (
direction,benchmark_bands), rather than reinvented per report. - Give
decision,digital_twin,optimization, and future AI agents (§37) one shared, correct-by-construction statistical vocabulary - percentile, confidence interval, rolling window, trend slope, anomaly flag - so those future packages consume Analytics' results instead of each defining their own, mutually inconsistent versions of "what is a rolling average." - Make data quality a visible, scored precondition, not a silent assumption. No trend, benchmark, or confidence interval is reported without an accompanying, inspectable judgment of how much the underlying data can be trusted (§25).
- Support both retrospective (batch) and live (streaming) operational use from one consistent object model, so a monthly report and a live operations-center tile compute the same statistics the same way (§27-§29).
3. Architectural Principles¶
- Computation-only, non-generative. Analytics computes deterministic statistics from data that already exists. It never decides what should happen next (that is
decision), never maintains simulated live state (that isdigital_twin), never searches a solution space (that isoptimization), and never trains or infers with a machine-learning model (out of scope entirely - see §4). - Consumption without redefinition. Analytics never recomputes, reinterprets, or shadows a KPI's formula. Any time a KPI value is needed, Analytics calls
kpis.KPIEngine/kpis.REGISTRY- it never reads raw rows and re-derives a KPI-shaped number itself. This is the single most important boundary in this specification (§9, §34). - Reuse over reinvention. Wherever a Foundation Layer package already defines a shape -
kpis.Window/RollingWindow/CumulativeWindow,kpis.ExecutionBackend,kpis.Aggregation,registry.Registry,registry.EntryPointDiscovery,core.Result/core.Maybe,core.BaseSpecification- Analytics composes it rather than defining a parallel concept with a different name (§5, §10, §11). - Interfaces before algorithms, where the algorithm is a modeling choice. Forecasting, anomaly detection, and outlier detection are each declared as a stable abstract contract now (§16, §18, §19); no specific algorithm is chosen or shipped for any of the three. This keeps the package strictly "computation-only" (no ML, no optimization) while still letting future, independently-versioned plugins implement against a fixed interface.
- Zero upward leakage. No Foundation Layer package (
corethroughkpis) importsanalytics, mechanically enforced by the same AST-basedTestNoForbiddenDependenciespattern every existing package already uses (tests/unit/<package>/test_public_api.py) - Analytics' own equivalent test asserts the reverse: nothing inanalyticsis reachable from a Foundation Layer package's import graph. - Metadata-light, not metadata-first.
kpis.KPIMetadatais a governance-grade, 29-field mandatory schema because a KPI is an audited business contract. AnAnalyticsModelis a computational strategy, not an audited business metric - its metadata (§31) is deliberately minimal. Copying the 29-field schema onto Analytics would be ceremony without a governance need behind it. - One extension mechanism, platform-wide. New trend, benchmark, baseline, forecasting, anomaly, or outlier strategies are added exactly the way a new KPI, connector, or ontology entity type is added: subclass, register, discover via entry points (§32, §33). No bespoke Analytics-specific plugin mechanism is invented.
4. Overall Architecture¶
Analytics occupies exactly one position in the platform's dependency chain - directly above kpis, directly below decision:
Everything below analytics exists, from Analytics' point of view, to produce well-formed inputs: events.EventStore for raw, queryable event rows; kpis.KPIEngine/kpis.REGISTRY for already-correct, single-point-in-time metric values. Everything above analytics (decision, digital_twin, optimization, simulation, agents, visualization - all future, undesigned packages, §37) exists to consume Analytics' outputs.
flowchart TB
subgraph inputs["Inputs (below analytics in the stack)"]
EVT["events - EventStore, EventBus, EventQuery"]
KPI["kpis - KPIEngine, KPIResult, KPIMetadata, REGISTRY"]
end
subgraph analytics["analytics package - THIS SPEC"]
ABS[AnalyticsModel + category bases]
PIPE[AnalyticsPipeline / PipelineStage]
AGG[AggregationEngine]
TS[TimeSeries]
STATS["Statistical primitives (describe/percentile/histogram/CI)"]
TREND[TrendModel]
BASE[BaselineModel]
BENCH[BenchmarkModel]
QUAL[DataQualityScorer]
FORE["ForecastingModel (interface only)"]
ANOM["AnomalyDetector (interface only)"]
OUT["OutlierDetector (interface only)"]
REG["AnalyticsRegistry (Registry Framework specialization)"]
end
subgraph consumers["Consumers (above analytics - future, undesigned)"]
DEC[decision]
TWIN[digital_twin]
OPT[optimization]
SIM[simulation]
AGENTS[agents]
end
EVT --> PIPE
KPI --> PIPE
KPI --> BENCH
REG --> ABS
ABS --> TREND
ABS --> BASE
ABS --> BENCH
ABS --> FORE
ABS --> ANOM
ABS --> OUT
PIPE --> AGG
PIPE --> TS
PIPE --> STATS
PIPE --> QUAL
PIPE --> consumers
Analytics is deliberately not a second execution engine competing with KPIEngine. It has no dependency-graph resolver of its own, no metric-code namespace, and no formula language - it is a statistical treatment layer over values kpis already produced, and over raw rows events already stores.
5. Dependency Graph¶
Permitted imports (platform layering rule, verbatim from this package's brief): analytics may import mineproductivity.core, mineproductivity.events, mineproductivity.ontology, mineproductivity.registry, mineproductivity.plugins, mineproductivity.connectors, and mineproductivity.kpis, and nothing else.
Actually exercised by this design: core (value objects, Result/Maybe, specifications, exceptions), events (EventStore, EventQuery, EventBus/Subscription for streaming, AsOf/ReplayHandle for point-in-time baselines), registry and plugins (the AnalyticsRegistry specialization and entry-point discovery, §33), and kpis (KPIEngine, KPIResult, KPIMetadata, Direction, Aggregation, Window/RollingWindow/CumulativeWindow, ExecutionBackend, REGISTRY). ontology is available for type hints where a result needs to be scoped to a typed entity (e.g. ontology.Fleet, ontology.Pit) but this design introduces no new ontology-derived concept. connectors is a permitted import under the platform-wide layering rule but is not exercised by any class in this specification - Analytics operates on already-ingested EventStore data and already-computed KPIResults, never on a vendor-specific wire format; §34 records this as a standing anti-pattern to guard against, not a gap to fill.
graph TD
core --> analytics
ontology --> analytics
events --> analytics
registry --> analytics
plugins --> analytics
connectors -.permitted, unused.-> analytics
kpis --> analytics
analytics --> decision["decision (future)"]
analytics --> digital_twin["digital_twin (future)"]
analytics --> optimization["optimization (future)"]
analytics --> simulation["simulation (future)"]
analytics --> agents["agents (future)"]
Depended on by (future, undesigned): decision, digital_twin, optimization, simulation, agents, visualization.
Forbidden, mechanically enforced:
- analytics MUST NOT be imported by core, ontology, events, registry, plugins, connectors, or kpis - no Foundation Layer file may contain import mineproductivity.analytics or from mineproductivity.analytics import ..., checked by an AST walk exactly like every existing package's TestNoForbiddenDependencies test.
- analytics MUST NOT import decision, digital_twin, optimization, simulation, agents, or visualization - those are all strictly above it and, as of this specification, do not yet exist.
- No cycle exists or is introduced: the chain core → ontology → events → kpis → analytics is a strict total order for every symbol this package uses.
6. Package Structure¶
src/mineproductivity/analytics/
├── __init__.py # public API surface (§7)
├── abstractions.py # AnalyticsModel (ABC), AnalyticsContext
├── metadata.py # AnalyticsMetadata (lightweight registration schema)
├── result.py # AnalyticsResult (base) and every concrete result dataclass
├── pipeline.py # AnalyticsPipeline, PipelineStage (ABC)
├── aggregation.py # AggregationEngine, GroupBySpec
├── windowing.py # RollingSpec (composes kpis.Window family)
├── timeseries.py # TimeSeries, TimeSeriesPoint
├── statistics.py # describe, percentile, histogram, distribution, confidence_interval
├── rolling.py # rolling_mean, rolling_std, rolling_apply
├── trend.py # TrendModel (ABC), LinearTrendModel
├── baseline.py # BaselineModel (ABC), RollingBaselineModel
├── benchmarking.py # BenchmarkModel (ABC), BandBenchmarkModel
├── quality.py # DataQualityScorer, MissingDataPolicy
├── forecasting.py # ForecastingModel (ABC) -- interface only, §16
├── anomaly.py # AnomalyDetector (ABC) -- interface only, §18
├── outliers.py # OutlierDetector (ABC) -- interface only, §19
├── batch.py # BatchAnalyticsRunner
├── streaming.py # StreamingAnalyticsSession
├── incremental.py # IncrementalAccumulator (Welford's algorithm)
├── _registry.py # REGISTRY, register (Registry Framework specialization)
├── exceptions.py
└── README.md
Twenty-one implementation modules plus __init__.py and README.md - comparable in scale to kpis (§6 of spec 05: twenty implementation modules). Every module below is specified against the same seven fields: Purpose, Responsibilities, Public Classes, Public Functions, Public API (what actually reaches analytics/__init__.py, as opposed to what merely exists in the module), Dependencies, and Extension Points. A field reading "None" is a deliberate statement, not an omission - e.g. a module with no public functions is not expected to grow one later just to fill the field.
abstractions.py¶
- Purpose: the "Analytics-as-object" root, mirroring
kpis.BaseKPI's "KPI-as-object" shape one layer up (§8). - Responsibilities: define the one method every registrable analytics strategy implements (
_analyze); define the non-overridden orchestration wrapper (analyze) that enforces the minimum-observations precondition; bundle the collaborators (EventStore,KPIEngine,ExecutionBackend) a concrete model may need. - Public Classes:
AnalyticsModel(ABC),AnalyticsContext. - Public Functions: None.
- Public API:
AnalyticsModel,AnalyticsContext- both re-exported fromanalytics/__init__.py(§7). - Dependencies:
core(Result),events(EventStore),kpis(KPIEngine,ExecutionBackend). - Extension Points: every category base in
trend.py/baseline.py/benchmarking.py/forecasting.py/anomaly.py/outliers.pysubclassesAnalyticsModel; this module itself never changes to admit a new category (§32).
metadata.py¶
- Purpose: the minimal registration schema for a discoverable
AnalyticsModel(§31). - Responsibilities: carry just enough structured information for registry introspection and entry-point discovery; enforce the closed
AnalyticsCategorynamespace; validate a non-emptycode. - Public Classes:
AnalyticsMetadata,AnalyticsCategory(enum). - Public Functions: None.
- Public API:
AnalyticsMetadata,AnalyticsCategory. - Dependencies:
core(BaseMetadata,ValidationError). - Extension Points: a new
AnalyticsCategorymember is a closed-enum, governance-reviewed change (mirrors thekpis.Aggregationclosed-enum rule, spec 05 §16.4) - never added casually alongside an unrelated model PR.
result.py¶
- Purpose: every concrete analytical output type (§30).
- Responsibilities: define one shared envelope (
AnalyticsResult) and the full family of concrete results built on it; keep every result type a frozen, serializable value object. - Public Classes:
AnalyticsResult,StatisticalSummary,TrendResult,BenchmarkResult,Baseline,DistributionSummary,Histogram,ConfidenceInterval,DataQualityScore,ForecastResult,AnomalyFlag,OutlierFlag. - Public Functions: None.
- Public API: every class listed above.
- Dependencies:
core(BaseValueObject),kpis(Direction, referenced byBenchmarkResult). - Extension Points: a new concrete result type is added only alongside a new category of
AnalyticsModel(§32) - this module does not accumulate speculative result shapes ahead of a model that produces them.
pipeline.py¶
- Purpose: compose ordered analytical steps over a
TimeSeries(§9). - Responsibilities: run stages in order; enforce that the final stage yields an
AnalyticsResult; hold no model-specific branching, mirroringKPIEngine's own "holds no metric logic" invariant one layer up (spec 05 AD-KP-01). - Public Classes:
PipelineStage(ABC),AnalyticsPipeline,ModelStage. - Public Functions: None.
- Public API:
AnalyticsPipeline,PipelineStage. (ModelStageis a concrete, always-available terminal stage rather than an extension point of its own; it is exported alongside the two abstractions it connects - see §7 for the exact list.) - Dependencies:
core(Result), this package's ownresult.py/timeseries.py. - Extension Points: a new pipeline stage is any
PipelineStageimplementation; no change toAnalyticsPipelineitself is ever required (§9, §32).
aggregation.py¶
- Purpose: group-and-reduce over series of raw values or
KPIResults, delegating tokpis.KPIEnginewhen KPI aggregation semantics apply (§10). - Responsibilities: pure statistical reduction for non-KPI series; correctness-preserving delegation back to
KPIEngine.execute()forRATIO/AVERAGE/WEIGHTED_AVERAGE-aggregation KPI series, never a naive combine of already-computed values. - Public Classes:
AggregationEngine,GroupBySpec. - Public Functions: None.
- Public API:
AggregationEngine,GroupBySpec. - Dependencies:
kpis(Aggregation,KPIEngine,ExecutionBackend,KPIResult),core(Result). - Extension Points: a new reduction kind (beyond
sum/mean/median) is an additive change toAggregationEngine.reduce'sreductionliteral, reviewed the same way a newkpis.Aggregationmember would be (§32.4).
windowing.py¶
- Purpose: the one new windowing idea Analytics needs beyond what
kpis.Window/RollingWindow/CumulativeWindowalready express (§11). - Responsibilities: represent either a time-based rolling window (by wrapping
kpis.RollingWindowdirectly) or a count-based window over the last N observations, for irregularly-sampled series; validate that exactly one of the two is supplied. - Public Classes:
RollingSpec. - Public Functions: None.
- Public API:
RollingSpec. - Dependencies:
kpis(RollingWindow),core(BaseValueObject,ValidationError). - Extension Points: none - this module is deliberately closed; a genuinely new windowing concept belongs in
kpis(the platform's one owner of theWindowfamily), not as a third parallel definition here.
timeseries.py¶
- Purpose: the ordered-observations shape every rolling/trend/distribution/benchmark computation in this package operates over (§12).
- Responsibilities: hold an ordered tuple of
TimeSeriesPoints; construct aTimeSeriesfrom aSequence[KPIResult]or directly from anEventStorequery. - Public Classes:
TimeSeries,TimeSeriesPoint. - Public Functions: None (
from_kpi_results/from_event_queryare classmethods onTimeSeries, not module-level functions). - Public API:
TimeSeries,TimeSeriesPoint. - Dependencies:
core(BaseValueObject),kpis(KPIResult),events(EventStore,EventQuery). - Extension Points: a third construction path (beyond
from_kpi_results/from_event_query) is added as a new classmethod without changing theTimeSeries/TimeSeriesPointshape itself.
statistics.py¶
- Purpose: stateless, deterministic statistical primitives - the "verbs" every concrete
AnalyticsModelcalls internally (§17, §21-§24). - Responsibilities: compute descriptive statistics, percentiles, histograms, distribution shape, and closed-form confidence intervals over plain numeric sequences.
- Public Classes: None (result types live in
result.py). - Public Functions:
describe,percentile,histogram,distribution,confidence_interval. - Public API: all five functions listed above.
- Dependencies:
coreonly (no third-party numerical dependency required for the reference implementation; §36). - Extension Points: a new closed-form statistical primitive (e.g. a skew-adjusted interval) is a new function here; a resampling-based method is explicitly deferred (§24, §37), not added ad hoc.
rolling.py¶
- Purpose: rolling-window statistical functions over a
TimeSeries(§20). - Responsibilities: apply a reduction over a sliding
RollingSpecwindow, representing "not yet enough data" as an absent point rather than a sentinel value. - Public Classes: None.
- Public Functions:
rolling_mean,rolling_std,rolling_apply. - Public API: all three functions listed above.
- Dependencies:
windowing.py(RollingSpec),timeseries.py(TimeSeries). - Extension Points: any further named rolling reduction is expected to go through
rolling_applyrather than growing the list of named functions (§20).
trend.py¶
- Purpose: deterministic trend fitting (§14).
- Responsibilities: characterize an observed
TimeSeries' direction and fit quality; never extrapolate beyond the observed window (that is forecasting, §16, a separate concern). - Public Classes:
TrendModel(ABC),LinearTrendModel(concrete, ships by default and is registered intoREGISTRYat import time, mirroring howkpis.standard_libraryself-registers, spec 05__init__.py). - Public Functions: None.
- Public API:
TrendModel,LinearTrendModel. - Dependencies:
abstractions.py(AnalyticsModel),statistics.py,timeseries.py. - Extension Points: a new concrete
TrendModelsubclass (§32.1) - e.g. a futureExponentialTrendModel(§33's worked example).
baseline.py¶
- Purpose: self-referential historical-norm computation (§15).
- Responsibilities: compute a trailing-window mean/standard-deviation band distinct from an externally-published benchmark band (§13).
- Public Classes:
BaselineModel(ABC),RollingBaselineModel(concrete, ships by default). - Public Functions: None.
- Public API:
BaselineModel,RollingBaselineModel. - Dependencies:
abstractions.py,rolling.py,statistics.py,windowing.py(RollingSpec). - Extension Points: a new concrete
BaselineModelsubclass (e.g. a seasonally-adjusted baseline) - §32.1.
benchmarking.py¶
- Purpose: classify a
KPIResultagainst its ownKPIMetadata.benchmark_bands/direction(§13). - Responsibilities: read benchmark metadata via
Registry.metadata_for, never define a parallel band schema; correctly invert the comparison forLOWER_IS_BETTERand handleTARGET_IS_BESTas distance-from-target. - Public Classes:
BenchmarkModel(ABC),BandBenchmarkModel(concrete, ships by default). - Public Functions: None.
- Public API:
BenchmarkModel,BandBenchmarkModel. - Dependencies:
kpis(KPIMetadata,Direction,REGISTRY),registry(Registry, for themetadata_fortype signature),abstractions.py. - Extension Points: a new concrete
BenchmarkModelsubclass (e.g. a peer-group-relative benchmark rather than a fixed-band one) - §32.1.
quality.py¶
- Purpose: data-quality scoring and missing-data policy (§25, §26).
- Responsibilities: score completeness/validity over a set of rows against required columns; define the closed, deterministic set of missing-data policies a pipeline stage may apply; provide the
PipelineStagewrapper (DataQualityStage) that letsDataQualityScorercompose directly into anAnalyticsPipeline(§9). - Public Classes:
DataQualityScorer,MissingDataPolicy(enum),DataQualityStage. - Public Functions: None.
- Public API:
DataQualityScorer,MissingDataPolicy,DataQualityStage. - Dependencies:
core(BaseSpecification, for filtering rows during scoring, the same reusable primitiveevents.EventFilteralready builds on),pipeline.py(PipelineStage, whichDataQualityStageimplements). - Extension Points: a new
MissingDataPolicymember is a closed-enum, governance-reviewed change (§26) - never a silent default change.
forecasting.py / anomaly.py / outliers.py¶
- Purpose: interface-only extension points (§16, §18, §19) - no concrete implementation in any of the three modules.
- Responsibilities: define a stable abstract contract (
_forecast/_detect) a future plugin implements against; define nothing else. - Public Classes:
ForecastingModel(ABC,forecasting.py),AnomalyDetector(ABC,anomaly.py),OutlierDetector(ABC,outliers.py). Their result types (ForecastResult,AnomalyFlag,OutlierFlag) live inresult.py, not in these modules. - Public Functions: None.
- Public API:
ForecastingModel,AnomalyDetector,OutlierDetector. - Dependencies:
abstractions.py,timeseries.py,result.py(for the return-type annotations only). - Extension Points: the entire purpose of these three modules - a concrete subclass of any one of the three ABCs is a first-class extension (§32.2), but is never added inside these modules themselves (§34).
batch.py / streaming.py / incremental.py¶
- Purpose: the three execution modes (§27-§29).
- Responsibilities:
batch.pyruns one pipeline once over a bounded input;streaming.pymaintains a long-livedEventBussubscription;incremental.pyprovides the O(1)-update numerical primitive both other modules use for unbounded input. - Public Classes:
BatchAnalyticsRunner(batch.py),StreamingAnalyticsSession(streaming.py),IncrementalAccumulator(incremental.py). - Public Functions: None.
- Public API: all three classes listed above.
- Dependencies:
events(EventStore,EventBus,Subscription),pipeline.py,statistics.py(StatisticalSummary, the shapeIncrementalAccumulator.snapshot()returns). - Extension Points: a new execution mode (e.g. a scheduled/batch-of-batches runner) is a new class in a new module, composing
AnalyticsPipelinethe same wayBatchAnalyticsRunnerdoes, rather than a change to either existing class.
_registry.py¶
- Purpose: the
AnalyticsModelregistry, following the exact patternkpis._registryestablished (spec 05 AD-KP-06) rather than reimplementing registration (§33). - Responsibilities: hold the process-wide
Registry[str, type[AnalyticsModel]]instance; validate a non-emptycodeat registration time; reject a duplicate, non-identical re-registration. - Public Classes: None (this module exposes a module-level constant and a function, not a class).
- Public Functions:
register. - Public API:
REGISTRY(module-levelRegistry[str, type[AnalyticsModel]]instance),register. - Dependencies:
registry(Registry),metadata.py(AnalyticsMetadata),exceptions.py. - Extension Points: none within this module itself - it is the extension mechanism (§33) other modules and third-party plugins use, not a surface that grows independently.
exceptions.py¶
- Purpose: the package's exception hierarchy, used throughout §8-§33 but introduced here as one module rather than scattered per-module definitions, mirroring
kpis.exceptions' single-module convention (spec 05 §6). - Responsibilities: define every raised error type this package's public API can produce.
- Public Classes:
AnalyticsValidationError(raised for malformedAnalyticsMetadata, e.g. an emptycode),InsufficientDataError(raised only where a caller explicitly requests raising behavior instead ofAnalyticsModel.analyze's default warning-carrying result, §8),AnalyticsModelNotFoundError(raised byREGISTRY.getfor an unregistered code, viaregistry.UnregisteredLookupError's same mechanism),AnalyticsVersionConflictError(raised byregisterwhen a plugin attempts to re-register an existingcodewith materially different metadata, mirroringkpis.KPIVersionConflictError, spec 05 §26). - Public Functions: None.
- Public API: all four exception classes listed above.
- Dependencies:
core(ValidationError,NotFoundError,RegistrationError- each Analytics exception subclasses the matchingcoreexception exactly as every other domain package's exceptions do, e.g.kpis.KPIValidationError(ValidationError), spec 05 §26). - Extension Points: a new exception type is added only alongside the specific failure mode it represents - this module does not pre-declare exceptions for capabilities that do not yet exist (e.g. no
AnalyticsForecastErrorexists yet, sinceForecastingModelships no concrete implementation to fail, §16).
7. Public API¶
from mineproductivity.analytics import (
# Abstractions
AnalyticsModel, AnalyticsContext,
# Metadata
AnalyticsMetadata, AnalyticsCategory,
# Result models
AnalyticsResult, StatisticalSummary, TrendResult, BenchmarkResult, Baseline,
DistributionSummary, Histogram, ConfidenceInterval, DataQualityScore,
AnomalyFlag, OutlierFlag,
# Time series
TimeSeries, TimeSeriesPoint,
# Pipelines
AnalyticsPipeline, PipelineStage, ModelStage,
# Aggregation and windowing
AggregationEngine, GroupBySpec, RollingSpec,
# Statistical primitives
describe, percentile, histogram, distribution, confidence_interval,
rolling_mean, rolling_std, rolling_apply,
# Category bases and default concrete models
TrendModel, LinearTrendModel,
BaselineModel, RollingBaselineModel,
BenchmarkModel, BandBenchmarkModel,
# Interfaces only -- no concrete implementation (§16, §18, §19)
ForecastingModel, AnomalyDetector, OutlierDetector,
# Data quality
DataQualityScorer, MissingDataPolicy, DataQualityStage,
# Execution modes
BatchAnalyticsRunner, StreamingAnalyticsSession, IncrementalAccumulator,
# Registry (Registry Framework specialization)
register, REGISTRY,
# Exceptions
AnalyticsValidationError, InsufficientDataError,
AnalyticsModelNotFoundError, AnalyticsVersionConflictError,
)
Every name above is intended to be stable once implementation begins (per this task's brief: "every proposed public API should be considered stable"). This list is deliberately smaller than it could be - no speculative "maybe useful" symbol is included; each name maps directly to one of the sections below.
8. Analytics Abstractions¶
class AnalyticsContext:
"""Bundles the collaborators an AnalyticsModel may need -- the
analytics-layer counterpart to KPIEngine's own constructor bundle
(kpis spec §10.8). Every field is optional except `event_store`
because some models (e.g. a pure statistical summary over an
already-fetched TimeSeries) need nothing else."""
def __init__(
self,
*,
event_store: "EventStore",
kpi_engine: "KPIEngine | None" = None,
backend: "ExecutionBackend | None" = None,
) -> None: ...
class AnalyticsModel(ABC):
"""The root of every registrable analytics strategy -- 'Analytics-
as-object,' the direct one-layer-up counterpart of kpis.BaseKPI. A
concrete leaf declares `meta: ClassVar[AnalyticsMetadata]` and
implements `_analyze`; everything else (input validation, result
envelope wrapping) is inherited, mirroring BaseKPI/`_compute`
exactly (kpis spec §10.3)."""
meta: ClassVar[AnalyticsMetadata]
@abstractmethod
def _analyze(
self, series: "TimeSeries", *, context: AnalyticsContext
) -> AnalyticsResult:
"""Pure function: a TimeSeries (of raw values or KPIResults) plus
context in, one AnalyticsResult out. MUST NOT raise for a
legitimately un-analyzable input (e.g. fewer observations than
the model requires) -- return an AnalyticsResult carrying a
warning, exactly the 'qualify, don't coerce' rule kpis.BaseKPI
already established (kpis spec §10.3, §26)."""
def analyze(self, series: "TimeSeries", *, context: AnalyticsContext) -> AnalyticsResult:
"""Non-overridden orchestration: checks `series` has the minimum
number of observations this model declares needing
(`meta.min_observations`), then calls `_analyze`."""
if len(series) < self.meta.min_observations:
return AnalyticsResult(
model_code=self.meta.code, warnings=(
f"insufficient data: {len(series)} observations, "
f"{self.meta.min_observations} required",
),
)
return self._analyze(series, context=context)
AnalyticsModel is the single abstraction every pluggable Analytics capability shares. It is deliberately not split into nine category families the way kpis.BaseKPI is (spec 05 §10.4) - Analytics has a small number of genuinely distinct category bases (TrendModel, BaselineModel, BenchmarkModel, ForecastingModel, AnomalyDetector, OutlierDetector, §14-§19), not nine namespace-shaped ones, because Analytics organizes by analytical function, not by mining domain namespace (that organizing axis belongs to kpis, and is not re-derived here).
classDiagram
class AnalyticsModel {
<<abstract>>
+meta: ClassVar~AnalyticsMetadata~
+analyze(series, context) AnalyticsResult
#_analyze(series, context) AnalyticsResult
}
class AnalyticsContext
class AnalyticsMetadata
class AnalyticsResult
AnalyticsModel --> AnalyticsMetadata
AnalyticsModel --> AnalyticsResult : produces
AnalyticsModel ..> AnalyticsContext : uses
AnalyticsModel <|-- TrendModel
AnalyticsModel <|-- BaselineModel
AnalyticsModel <|-- BenchmarkModel
AnalyticsModel <|-- ForecastingModel
AnalyticsModel <|-- AnomalyDetector
AnalyticsModel <|-- OutlierDetector
TrendModel <|-- LinearTrendModel
BaselineModel <|-- RollingBaselineModel
BenchmarkModel <|-- BandBenchmarkModel
AnalyticsResult <|-- StatisticalSummary
AnalyticsResult <|-- TrendResult
AnalyticsResult <|-- BenchmarkResult
AnalyticsResult <|-- Baseline
AnalyticsResult <|-- DistributionSummary
AnalyticsResult <|-- Histogram
AnalyticsResult <|-- ConfidenceInterval
AnalyticsResult <|-- DataQualityScore
AnalyticsResult <|-- ForecastResult
class Registry~TKey,TItem~
Registry <|.. AnalyticsRegistryShape : specialized as REGISTRY
Thread safety and statelessness. Exactly like kpis.BaseKPI (spec 05 §24), every AnalyticsModel subclass MUST be stateless across analyze() calls - no instance attribute is mutated by _analyze, so a single LinearTrendModel/RollingBaselineModel/BandBenchmarkModel instance is safe to share and invoke concurrently from multiple threads. analytics.REGISTRY inherits Registry's own thread-safety contract (spec 03 §24: read-only and thread-safe after startup discovery). This statelessness guarantee explicitly does not extend to IncrementalAccumulator (§29), which is deliberately, visibly mutable - its own thread-safety contract is specified separately in §29, not assumed from this section.
9. Metric Pipelines¶
An AnalyticsPipeline is an ordered sequence of PipelineStages applied to one TimeSeries (or a Sequence[kpis.KPIResult] wrapped into one via TimeSeries.from_kpi_results, §12). This is the composition mechanism binding together data-quality handling, aggregation, and one or more AnalyticsModel invocations into one auditable, reusable unit - the Analytics-layer equivalent of the Cookbook's "Putting Everything Together" pipeline (KPI spec 05 §29), one level up.
class PipelineStage(ABC):
"""One step in an AnalyticsPipeline. Stateless and composable --
a new stage never requires changing AnalyticsPipeline itself
(§32)."""
@abstractmethod
def process(self, series: "TimeSeries", *, context: AnalyticsContext) -> "TimeSeries":
"""Transform one TimeSeries into another (e.g. missing-data
handling, aggregation) -- OR, for a terminal stage, wrap the
series into an AnalyticsResult (see `ModelStage` below)."""
class ModelStage(PipelineStage):
"""A terminal stage that hands the (by now cleaned/aggregated)
series to one AnalyticsModel and yields its AnalyticsResult."""
def __init__(self, model: AnalyticsModel) -> None: ...
class AnalyticsPipeline:
"""An ordered Sequence[PipelineStage], run in order over one input
TimeSeries. Mirrors the shape of Cookbook Part I Ch. 10's "Putting
Everything Together" narrative -- fetch, clean, aggregate, analyze --
but generalized to any analytical question, not only the
CSV -> Connector -> EventStore -> KPIEngine path that spec 05
already owns."""
def __init__(self, stages: "Sequence[PipelineStage]") -> None: ...
def run(self, series: "TimeSeries", *, context: AnalyticsContext) -> Result[AnalyticsResult]:
"""Runs every stage in order; a non-terminal stage's output
feeds the next stage's input. The last stage MUST be a
ModelStage (or otherwise yield an AnalyticsResult) or `run`
returns Result.err(AnalyticsValidationError(...))."""
sequenceDiagram
participant App
participant Pipe as AnalyticsPipeline
participant Qual as DataQualityScorer (stage)
participant Agg as AggregationEngine (stage)
participant Model as ModelStage(TrendModel)
App->>Pipe: run(series, context)
Pipe->>Qual: process(series, context)
Qual-->>Pipe: cleaned series + DataQualityScore attached
Pipe->>Agg: process(cleaned series, context)
Agg-->>Pipe: grouped/reduced series
Pipe->>Model: process(grouped series, context)
Model->>Model: TrendModel.analyze(series, context)
Model-->>Pipe: TrendResult
Pipe-->>App: Result.ok(TrendResult)
A pipeline never contains model-specific branching (mirroring KPIEngine's own "holds no metric logic" invariant, spec 05 AD-KP-01, one layer up): AnalyticsPipeline.run calls each stage's process uniformly and has no knowledge of which concrete AnalyticsModel a ModelStage wraps.
Worked example. The following is illustrative of the intended end-to-end shape once implemented - a 90-day trailing trend over PROD.TPH, fed by kpis.KPIEngine, gated by a data-quality check, exactly the composition §9's sequence diagram depicts:
from mineproductivity.kpis import KPIEngine, REGISTRY as KPI_REGISTRY
from mineproductivity.analytics import (
AnalyticsContext, AnalyticsPipeline, ModelStage, TimeSeries,
DataQualityStage, # a PipelineStage wrapping DataQualityScorer (§25)
LinearTrendModel,
)
engine = KPIEngine(store=event_store, registry=KPI_REGISTRY, backend=active_backend, cache=result_cache)
daily_results = [
engine.execute("PROD.TPH", window="day", scope={"day": day, "pit": "north"}).unwrap()
for day in last_90_days
]
series = TimeSeries.from_kpi_results(daily_results) # timestamps supplied alongside daily_results
context = AnalyticsContext(event_store=event_store, kpi_engine=engine, backend=active_backend)
pipeline = AnalyticsPipeline(stages=(
DataQualityStage(required_columns=("payload_t", "operating_h")),
ModelStage(LinearTrendModel()),
))
result = pipeline.run(series, context=context)
if result.is_ok:
trend = result.unwrap()
print(f"{trend.direction} at {trend.slope:.2f} t/h per day (R^2={trend.r_squared:.2f})")
This example deliberately reuses kpis.KPIEngine.execute() for every single-day value (§3.2) - nothing in analytics recomputes PROD.TPH from raw rows; the pipeline's only job is to characterize the series of ninety already-correct daily values kpis produced.
10. Aggregation Engine¶
class GroupBySpec:
"""Which field(s) to group a TimeSeries by before reduction, e.g.
("equipment_id",) or ("pit", "shift")."""
def __init__(self, by: "tuple[str, ...]") -> None: ...
class AggregationEngine:
"""Group-and-reduce over raw numeric series or over KPIResult
series, delegating correctly to kpis.KPIEngine whenever the
underlying KPI's Aggregation semantics require it."""
def __init__(self, *, backend: "ExecutionBackend | None" = None) -> None: ...
def reduce(
self, series: "TimeSeries", by: GroupBySpec, *, reduction: "Literal['sum','mean','median']"
) -> "Mapping[tuple[str, ...], AnalyticsResult]":
"""Pure statistical reduction over a series of plain numeric
observations -- no KPI semantics involved. Safe for any input
that is not itself a KPIResult series."""
def reduce_kpi_results(
self,
results: "Sequence[KPIResult]",
*,
engine: "KPIEngine",
combined_scope: "Mapping[str, str]",
) -> Result[KPIResult]:
"""Group-and-reduce over KPIResult observations, respecting
KPIMetadata.aggregation (kpis spec §10.2, §19's 'RATIO-never-
averaged' rule) one layer up from where kpis itself enforces it.
For ADDITIVE/CUMULATIVE-aggregation KPIs, this is a direct sum.
For RATIO/AVERAGE/WEIGHTED_AVERAGE-aggregation KPIs, this method
does NOT average `results`' already-computed `.value`s -- it
re-invokes `engine.execute(code, window=..., scope=combined_scope)`
over the union scope, exactly reusing kpis' own engine-level
ratio-correctness guarantee instead of re-deriving (and risking
getting wrong) the same rule a second time (§34)."""
This is the one place in this specification where an architectural decision made in the (locked) KPI Engine spec is deliberately reused one level up rather than merely respected: cross-group aggregation of a RATIO-aggregation KPI has exactly the same correctness hazard as cross-period aggregation (KPI spec 05 §13.3's worked example - averaging two shifts' TPH values is wrong; the correct answer re-derives from summed numerator/denominator over the combined scope). AggregationEngine.reduce_kpi_results resolves this by calling back into KPIEngine.execute with a wider scope rather than attempting to combine KPIResult.value fields directly - the engine that already enforces this rule correctly (spec 05 AD-KP-02) is asked to enforce it again over a different scope, not reimplemented.
11. Windowing¶
Analytics does not define a new time-window concept where kpis.Window/RollingWindow/CumulativeWindow (spec 05 §10.10) already suffices - those three types are imported and used directly wherever a time-bounded scope is needed (e.g. "this shift," "this rolling 7 days," "month-to-date"). The one genuinely new idea this package introduces is a count-based rolling window - the last N observations of a TimeSeries, regardless of how much wall-clock time they span - which matters for irregularly-sampled series (e.g. shift-level KPIResults, which do not arrive at a fixed cadence) where "last 7 days" and "last 14 shifts" are not interchangeable.
@dataclass(frozen=True, slots=True)
class RollingSpec(BaseValueObject):
"""Either a time-based rolling window (delegates to kpis.RollingWindow
directly) or a count-based window over the last `periods`
observations. Exactly one of `time_window`/`periods` is set."""
time_window: "RollingWindow | None" = field(default=None, kw_only=True)
periods: int | None = field(default=None, kw_only=True)
min_periods: int = field(default=1, kw_only=True)
def validate(self) -> None:
if (self.time_window is None) == (self.periods is None):
raise ValidationError("RollingSpec requires exactly one of time_window or periods")
RollingSpec is consumed by rolling.py's functions (§20) and by BaselineModel (§15); it is never redefined per-caller.
12. Time-Series Analytics¶
@dataclass(frozen=True, slots=True)
class TimeSeriesPoint(BaseValueObject):
"""One observation: a timestamp and a value, plus the originating
scope (mirrors KPIResult.scope, kpis spec §10.6)."""
timestamp: datetime
value: float
scope: "Mapping[str, str]" = field(default_factory=dict, kw_only=True)
@dataclass(frozen=True, slots=True)
class TimeSeries(BaseValueObject):
"""An ordered (by timestamp) sequence of TimeSeriesPoints -- the one
shape every rolling/trend/distribution/benchmark computation in this
package operates over."""
points: "tuple[TimeSeriesPoint, ...]"
def __len__(self) -> int: ...
def values(self) -> "tuple[float, ...]": ...
@classmethod
def from_kpi_results(cls, results: "Sequence[KPIResult]") -> "TimeSeries":
"""Wrap a Sequence[KPIResult] into a TimeSeries, deriving each
point's timestamp from the scope the caller supplies at
KPIEngine.execute() time -- KPIResult itself carries no
timestamp field (kpis spec §10.6), so callers assembling a
TimeSeries from KPI results must supply timestamps alongside
the results being wrapped."""
@classmethod
def from_event_query(cls, store: "EventStore", query: "EventQuery", *, value_field: str) -> "TimeSeries":
"""Wrap the result of EventStore.query(query) into a TimeSeries,
reading `value_field` off each envelope's payload -- for direct
statistical description of raw event data without going through
a KPI at all (e.g. distribution analysis of raw cycle payloads,
§23)."""
TimeSeries is intentionally the only series shape in this package. Every statistical primitive, every rolling function, every category model operates on TimeSeries - there is no second, parallel "just a list of floats" convention anywhere in the public API, even though several functions (percentile, histogram, distribution) only need the values, not the timestamps (they call .values()).
13. Benchmarking¶
Benchmarking answers "how does this value compare to the published bands" - and per the KPI Engine spec's own scope boundary (spec 05 §4: "a future analytics package consumes kpis' KPIResult objects... but kpis does not implement benchmarking itself, only exposes the metadata (target, direction) benchmarking needs"), Analytics implements the comparison logic against metadata kpis already carries, rather than defining a second, parallel benchmark schema.
class BenchmarkModel(AnalyticsModel, ABC):
"""Category base for benchmarking strategies."""
class BandBenchmarkModel(BenchmarkModel):
"""The default, concrete benchmarking strategy: classify a
KPIResult's value against its own KPIMetadata.benchmark_bands,
respecting KPIMetadata.direction (kpis spec §10.1, §10.2) --
HIGHER_IS_BETTER and LOWER_IS_BETTER invert the comparison;
TARGET_IS_BEST compares distance-from-target rather than raw
magnitude."""
def benchmark(self, result: "KPIResult", *, registry: "Registry[str, type]") -> BenchmarkResult:
metadata = registry.metadata_for(result.code) # Maybe[KPIMetadata], kpis spec §10.1
...
@dataclass(frozen=True, slots=True)
class BenchmarkResult(AnalyticsResult):
kpi_code: str
value: float
band: str # e.g. "top_quartile", "industry_average", "below_average"
direction: "Direction" # kpis.Direction, reused verbatim (§34)
BandBenchmarkModel reads KPIMetadata.benchmark_bands (a Mapping[str, str], spec 05 §10.1 field 19) via Registry.metadata_for (spec 03's generic Registry method, already used identically by kpis._registry.register, §33) - no new metadata field is added anywhere in kpis, and no parallel band schema is introduced in analytics.
14. Trend Analysis¶
class TrendModel(AnalyticsModel, ABC):
"""Category base for trend-fitting strategies."""
class LinearTrendModel(TrendModel):
"""The default, concrete trend strategy: ordinary-least-squares
linear fit over a TimeSeries' (timestamp, value) pairs. Fully
deterministic and closed-form -- no forecasting, no extrapolation
beyond the observed window (that is §16's job, and out of scope for
this model)."""
def _analyze(self, series: TimeSeries, *, context: AnalyticsContext) -> "TrendResult": ...
@dataclass(frozen=True, slots=True)
class TrendResult(AnalyticsResult):
slope: float
intercept: float
r_squared: float
direction: "Literal['increasing', 'decreasing', 'flat']"
window: "Window | RollingSpec"
LinearTrendModel never extrapolates a future value - it characterizes the observed series only (slope, fit quality). Producing a predicted future value is forecasting (§16), a deliberately separate, interface-only capability, so that "what has been happening" (trend, always available) and "what might happen next" (forecast, a modeling choice deferred to a future plugin) are never confused as the same computation.
15. Baseline Computation¶
class BaselineModel(AnalyticsModel, ABC):
"""Category base for self-referential historical-norm computation --
distinct from BenchmarkModel (§13), which compares against an
externally published target/band. A Baseline answers 'is this
normal for *this* asset/site, historically,' not 'how does this
compare to the industry.'"""
class RollingBaselineModel(BaselineModel):
"""The default, concrete baseline strategy: trailing-window mean and
standard deviation, forming a [mean - k*std, mean + k*std] band."""
def __init__(self, *, spec: RollingSpec, k: float = 2.0) -> None: ...
@dataclass(frozen=True, slots=True)
class Baseline(AnalyticsResult):
mean: float
std: float
lower: float
upper: float
spec: RollingSpec
A Baseline is the reference band AnomalyDetector/OutlierDetector implementations (§18, §19) are expected to compare fresh observations against, once such a plugin exists - this package computes and exposes the baseline; it does not itself decide whether a new point violates it (that classification is the detector interface's job, kept deliberately separate per §3.4).
16. Forecasting Interfaces (interface only)¶
class ForecastingModel(AnalyticsModel, ABC):
"""The contract a future forecasting plugin implements. THIS MODULE
SHIPS NO CONCRETE SUBCLASS -- choosing a forecasting algorithm
(exponential smoothing, ARIMA, or anything else) is a modeling
decision this package deliberately does not make (§3.4). Defining
the contract now lets a future, independently-versioned plugin
register against a stable interface without waiting for a future
revision of this specification."""
@abstractmethod
def _forecast(
self, series: TimeSeries, *, horizon: int, context: AnalyticsContext
) -> "ForecastResult":
"""`horizon` future points, each with a point estimate and an
uncertainty band (ConfidenceInterval, §24)."""
@dataclass(frozen=True, slots=True)
class ForecastResult(AnalyticsResult):
horizon: int
predicted: "tuple[float, ...]"
intervals: "tuple[ConfidenceInterval, ...]"
No AnalyticsPipeline, AnalyticsRegistry entry, or example in this specification instantiates ForecastingModel - it exists purely as a type contract future work implements against (§37).
17. Statistical Summaries¶
@dataclass(frozen=True, slots=True)
class StatisticalSummary(AnalyticsResult):
n: int
mean: float
std: float
minimum: float
maximum: float
percentiles: "Mapping[int, float]" # e.g. {50: ..., 90: ..., 99: ...}
describe is the Analytics-layer equivalent of a spreadsheet's "Descriptive Statistics" tool, over any TimeSeries - raw event values or KPIResult series alike - and is the primitive most other concrete models (RollingBaselineModel, BandBenchmarkModel's input validation) call internally rather than recomputing mean/std themselves.
18. Anomaly Detection Interfaces¶
class AnomalyDetector(AnalyticsModel, ABC):
"""Contract for a future anomaly-detection plugin. THIS MODULE SHIPS
NO CONCRETE SUBCLASS, for the same reason as ForecastingModel
(§16): 'is this point anomalous' is a modeling choice (z-score
threshold? seasonal decomposition? isolation-forest-style
ensembling?) this package does not make on the implementer's
behalf. A future detector is expected to be built on top of the
primitives this package DOES ship (`describe`, `Baseline`,
`rolling_std`), not on a new statistical foundation."""
@abstractmethod
def _detect(
self, series: TimeSeries, *, baseline: Baseline | None, context: AnalyticsContext
) -> "Sequence[AnomalyFlag]": ...
@dataclass(frozen=True, slots=True)
class AnomalyFlag(BaseValueObject):
timestamp: datetime
observed_value: float
expected_value: float | None
severity: "Literal['low', 'medium', 'high']"
19. Outlier Detection Interfaces¶
class OutlierDetector(AnalyticsModel, ABC):
"""Contract for a future outlier-detection plugin -- distinct from
AnomalyDetector (§18) in scope: an outlier is a single observation
unusual relative to a static distribution (e.g. via IQR or z-score
against `DistributionSummary`, §23); an anomaly is a point unusual
relative to a *temporal* baseline (§15). The two interfaces are
kept separate because the reference data they compare against
differs structurally (a distribution vs. a rolling baseline), even
though a future implementation could satisfy both. THIS MODULE
SHIPS NO CONCRETE SUBCLASS, for the same reason as §16/§18."""
@abstractmethod
def _detect(
self, series: TimeSeries, *, distribution: "DistributionSummary", context: AnalyticsContext
) -> "Sequence[OutlierFlag]": ...
@dataclass(frozen=True, slots=True)
class OutlierFlag(BaseValueObject):
index: int
value: float
method_hint: str
20. Rolling Statistics¶
def rolling_mean(series: TimeSeries, spec: RollingSpec) -> TimeSeries: ...
def rolling_std(series: TimeSeries, spec: RollingSpec) -> TimeSeries: ...
def rolling_apply(series: TimeSeries, spec: RollingSpec, fn: "Callable[[Sequence[float]], float]") -> TimeSeries: ...
rolling_mean/rolling_std are the two named, ready-made reductions; rolling_apply is the escape hatch for any other reduction a caller needs (e.g. a rolling percentile) without this package growing an unbounded number of named rolling_* functions - directly following "prefer fewer, carefully designed interfaces." Each returns a new TimeSeries of the same length as the input (points before spec.min_periods observations are available carry NaN-equivalent - represented as absent points rather than a sentinel float, so a caller cannot mistake "not yet enough data" for a computed zero, mirroring kpis.KPIResult's "never silently returns zero" rule, spec 05 §26).
21. Percentiles¶
def percentile(values: "Sequence[float]", q: float) -> float:
"""Linear-interpolation percentile (the same convention NumPy's
default `numpy.percentile` and pandas' default `.quantile` use),
named explicitly rather than left to a caller to discover which
interpolation convention a third-party library defaults to."""
percentile underlies StatisticalSummary.percentiles (§17) and Histogram's optional percentile-based bin edges (§22); it operates on plain Sequence[float], not TimeSeries, since a percentile calculation has no meaningful use for the timestamp dimension.
22. Histograms¶
@dataclass(frozen=True, slots=True)
class Histogram(AnalyticsResult):
bin_edges: "tuple[float, ...]"
counts: "tuple[int, ...]"
def histogram(values: "Sequence[float]", *, bins: "int | Sequence[float]" = 10) -> Histogram:
"""`bins` as an int requests that many equal-width bins spanning
[min(values), max(values)]; `bins` as a Sequence[float] requests
caller-supplied edges (e.g. the site's own published benchmark
bands, §13, when a histogram is being drawn against them)."""
23. Distribution Analysis¶
@dataclass(frozen=True, slots=True)
class DistributionSummary(AnalyticsResult):
mean: float
std: float
skewness: float
kurtosis: float
percentiles: "Mapping[int, float]"
def distribution(values: "Sequence[float]") -> DistributionSummary: ...
DistributionSummary is a superset of StatisticalSummary (§17) - it adds shape descriptors (skewness, kurtosis) that describe() deliberately omits, keeping describe() cheap and distribution() the more complete, slightly more expensive call for callers who explicitly need distribution shape (e.g. a future OutlierDetector implementation, §19).
24. Confidence Intervals¶
@dataclass(frozen=True, slots=True)
class ConfidenceInterval(AnalyticsResult):
lower: float
upper: float
confidence: float # e.g. 0.95
method: "Literal['normal', 't']"
def confidence_interval(
values: "Sequence[float]", *, confidence: float = 0.95, method: "Literal['normal', 't']" = "t"
) -> ConfidenceInterval:
"""Closed-form only: normal-approximation or Student's t-interval
around the sample mean. Resampling-based methods (e.g. bootstrap)
are a documented future extension (§37), not shipped now, to keep
the reference implementation's dependency footprint and
determinism guarantees simple (a bootstrap interval is stochastic
unless a seed is pinned, which conflicts with this package's
'deterministic, no randomness' default posture, §36)."""
25. Data Quality Scoring¶
class DataQualityScorer:
"""Produces a DataQualityScore for a set of rows against a set of
required columns -- the Analytics-layer counterpart of
BaseKPI._required_columns()'s missing-column check (kpis spec
§10.3), generalized into a graded score rather than a binary
present/absent warning."""
def score(
self, rows: "Sequence[Mapping[str, Any]]", *, required_columns: "tuple[str, ...]"
) -> DataQualityScore: ...
@dataclass(frozen=True, slots=True)
class DataQualityScore(AnalyticsResult):
completeness: float # fraction of required_columns present across rows
validity: float # fraction of present values passing basic type/range checks
overall_score: float # completeness * validity, the single number a pipeline gates on
reasons: "tuple[str, ...]"
completeness is a pure presence check (is the required column there at all, per row); validity is deliberately a basic check only - non-null, correct primitive type, and, where the caller supplies one, within a declared numeric range (e.g. operating_h >= 0) - never a domain-semantic check (that already happened upstream, in events.EventValidator, events spec 01 §19, and ontology.OntologyValidator, ontology spec 02). DataQualityScorer does not re-validate what events/ontology already validated; it scores what survived that validation for this specific analytical question's required fields, which may be a narrower or differently-shaped set than what the event schema itself mandates. overall_score is the single number DataQualityStage (§6) gates a pipeline on - a caller-configurable minimum threshold below which the stage attaches a warning to every downstream result rather than silently proceeding as if the data were fully trustworthy.
No trend, benchmark, baseline, or confidence interval in this package's worked example (§9) is ever presented without an accompanying DataQualityScore computed over the same input rows - this is a deliberate, repeated convention, not a one-off feature, directly serving Business Objective 4 (§2).
26. Missing Data Handling¶
class MissingDataPolicy(Enum):
"""Closed enum, mirroring the closed-enum-change-requires-governance
rule already established for kpis.Aggregation (spec 05 §16.4)."""
EXCLUDE = "exclude" # drop rows missing any required field
FLAG_ONLY = "flag_only" # keep rows, record in DataQualityScore.reasons
FORWARD_FILL = "forward_fill" # carry the last observed value forward
MEAN_FILL = "mean_fill" # substitute the series' own mean
All four policies are deterministic, closed-form operations - none of them predicts, models, or infers a missing value from other fields (that would cross into modeling territory this package deliberately avoids, §3.1). MissingDataPolicy is consumed by a dedicated PipelineStage (§9) that runs before any statistical or model stage, so every downstream computation in a pipeline sees data that has already been handled according to one explicit, inspectable policy rather than an implicit default.
27. Streaming Analytics¶
class StreamingAnalyticsSession:
"""A long-lived session that subscribes to an events.EventBus and
incrementally updates one or more IncrementalAccumulators as new
envelopes arrive, without ever re-scanning the full historical
EventStore. The live-operations-center counterpart of
BatchAnalyticsRunner (§28)."""
def __init__(self, *, bus: "EventBus", accumulators: "Mapping[str, IncrementalAccumulator]") -> None: ...
def start(self) -> "Subscription":
"""Subscribes to `bus`; each published EventEnvelope updates the
relevant accumulator(s) (events.EventBus.publish/Subscription,
events spec 01 §8)."""
def snapshot(self, key: str) -> StatisticalSummary:
"""The current, up-to-the-last-event StatisticalSummary for
`key`, read from its IncrementalAccumulator without touching
the EventStore at all."""
sequenceDiagram
participant Bus as events.EventBus
participant Sess as StreamingAnalyticsSession
participant Acc as IncrementalAccumulator
Bus->>Sess: publish(envelope)
Sess->>Acc: update(envelope.payload.value)
Acc-->>Sess: O(1) updated mean/variance
Note over Sess: no re-scan of EventStore history
StreamingAnalyticsSession.start() returns the same events.Subscription handle EventBus.subscribe() already returns (events spec 01 §8) rather than a new, Analytics-specific subscription concept - unsubscribing (ending the session) is exactly subscription.cancel() (or whichever cancellation shape Subscription already defines), not a second lifecycle mechanism this package introduces. A session is expected to be long-lived but restartable-from-cold: if a session's process restarts, its IncrementalAccumulators start from zero rather than attempting to replay history through the bus - a caller that needs a warm-started session (e.g. "resume today's rolling average after a restart") is expected to seed the accumulator from a BatchAnalyticsRunner pass over the relevant EventQuery window first (§28), then call start() to continue incrementally from that point; StreamingAnalyticsSession itself performs no such seeding automatically, since deciding how far back to seed is a policy choice this package does not make on the caller's behalf.
The Event Framework spec's own concurrency contract (events spec 01 §25) guarantees EventBus.publish() is only ever called after the corresponding append() has confirmed durability, but makes no guarantee about which thread a given subscriber's handler runs on, nor that a single subscriber never receives two concurrent calls. StreamingAnalyticsSession therefore does not assume single-threaded delivery: it is responsible for serializing concurrent update() calls per accumulator key exactly as §29 describes, rather than assuming the bus provides that serialization on its behalf.
28. Batch Analytics¶
class BatchAnalyticsRunner:
"""Runs one AnalyticsPipeline once over a bounded EventQuery or a
bounded Sequence[KPIResult] and returns a single AnalyticsResult --
the 'normal,' retrospective-report mode, in contrast to
StreamingAnalyticsSession's live, unbounded mode (§27)."""
def __init__(self, *, pipeline: AnalyticsPipeline, context: AnalyticsContext) -> None: ...
def run(self, series: TimeSeries) -> Result[AnalyticsResult]: ...
BatchAnalyticsRunner is a thin, named wrapper over AnalyticsPipeline.run (§9) - it exists as its own class (rather than callers invoking AnalyticsPipeline.run directly) so that "batch" and "streaming" are two clearly-named, symmetrical entry points in the public API (§7), matching how a reader of this specification's table of contents (§27-§29) expects to find them.
29. Incremental Computation¶
class IncrementalAccumulator:
"""Welford's online algorithm for numerically-stable, streaming mean
and variance: O(1) update per new observation, O(1) memory,
regardless of how many observations have been seen. This is the
algorithmic primitive both StreamingAnalyticsSession (§27) and,
optionally, BatchAnalyticsRunner (§28, for very large row counts
that should not be held in memory at once) use to avoid an O(n)
re-scan on every update. A well-known, deterministic, closed-form
numerical method -- not a statistical model and not machine
learning."""
def update(self, value: float) -> None: ...
def snapshot(self) -> StatisticalSummary: ...
Thread safety. Unlike every AnalyticsModel subclass (§8, which MUST be stateless), IncrementalAccumulator is deliberately, visibly mutable - update() changes internal running-mean/running-variance state. It is therefore not safe to share a single instance across threads without external synchronization. StreamingAnalyticsSession (§27) owns exactly one IncrementalAccumulator per tracked key and is expected to serialize calls to update() for a given key (e.g. via a per-key lock, mirroring kpis.ResultCache's own per-key-write concurrency contract, spec 05 §24) rather than exposing concurrent, unsynchronized writers to the same accumulator. Independent accumulators (different keys) have no shared state and may update fully in parallel. snapshot() is read-only and may be called at any time without blocking a concurrent update() beyond whatever the chosen synchronization strategy requires; it never mutates the accumulator.
30. Result Models¶
@dataclass(frozen=True, slots=True)
class AnalyticsResult(BaseValueObject):
"""The shared envelope every concrete Analytics result composes.
Mirrors KPIResult's role (kpis spec §10.6) as the one result shape
-- except Analytics genuinely has more than one *kind* of output, so
this is a shared base rather than kpis' single concrete type."""
model_code: str = field(default="", kw_only=True)
computed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc), kw_only=True)
warnings: "tuple[str, ...]" = field(default=(), kw_only=True)
Every result type this specification defines - StatisticalSummary (§17), TrendResult (§14), BenchmarkResult (§13), Baseline (§15), DistributionSummary (§23), Histogram (§22), ConfidenceInterval (§24), DataQualityScore (§25), ForecastResult (§16) - subclasses AnalyticsResult. AnomalyFlag (§18) and OutlierFlag (§19) are deliberately plain BaseValueObjects, not AnalyticsResult subclasses, since they represent one flagged observation within a series, not a summary result about a series - the same distinction kpis draws between KPIResult (a computed value) and a raw input row (not a value object at all).
Serialization. Every AnalyticsResult subclass is, like kpis.KPIResult/KPIMetadata (spec 05 §21), a core.BaseValueObject and therefore serializes via core.serialization (DataclassSerializer/to_dict) with no bespoke per-type serializer - this is what lets a future decision/digital_twin/agents consumer (§37) treat any Analytics result as plain, schema-stable structured data (JSON-compatible via to_dict) without depending on analytics internals beyond the dataclass shapes already declared here. There is no AnalyticsResult.to_frame() equivalent to KPIResult.to_frame() (spec 05 §10.6) in this design - a TimeSeries (§12), not an individual AnalyticsResult, is the natural unit for tabular export, and TimeSeries gains a to_frame()-equivalent only if a future revision finds a concrete need; this specification does not speculate one in now.
31. Metadata¶
class AnalyticsCategory(Enum):
"""Closed enum -- adding a member is a governance-reviewed change,
mirroring kpis.Aggregation's closed-enum rule (spec 05 §16.4)."""
TREND = "trend"
BASELINE = "baseline"
BENCHMARK = "benchmark"
FORECASTING = "forecasting"
ANOMALY = "anomaly"
OUTLIER = "outlier"
@dataclass(frozen=True, slots=True)
class AnalyticsMetadata(BaseMetadata):
"""The minimal registration schema for a discoverable AnalyticsModel
-- deliberately not a copy of kpis.KPIMetadata's 29-field governance
schema (§3.6): an AnalyticsModel is a computational strategy, not an
audited business metric."""
code: str # e.g. "TREND.Linear"
category: AnalyticsCategory = field(kw_only=True)
description: str = field(kw_only=True)
min_observations: int = field(default=2, kw_only=True)
version: str = field(default="1.0.0", kw_only=True)
def validate(self) -> None:
if not self.code.strip():
raise ValidationError("AnalyticsMetadata.code must not be empty")
AnalyticsMetadata.code follows the same NAMESPACE.Name shape kpis.parse_identifier already validates (spec 05 §20) for consistency of convention, but Analytics does not reuse kpis.parse_identifier itself (that function is scoped to kpis' own controlled namespace list - PROD, UTIL, MAINT, etc. - which does not include an Analytics namespace); analytics defines its own, much smaller closed namespace list matching AnalyticsCategory's members (TREND, BASELINE, BENCHMARK, FORECASTING, ANOMALY, OUTLIER).
32. Extension Points¶
- New concrete strategy within an existing category. Subclass
TrendModel,BaselineModel, orBenchmarkModel, completeAnalyticsMetadata, implement_analyze, decorate with@register(§33). No existing model class is ever edited to add a new one - identical rule tokpis(spec 05 §16.1). - A concrete forecasting/anomaly/outlier implementation. Subclass
ForecastingModel/AnomalyDetector/OutlierDetector(§16, §18, §19) - the first such subclass to exist in the ecosystem, whether shipped by this project in a later release or by a third party, is exactly as "first-class" as any built-in model; the ABC makes no distinction. - A new
PipelineStage. Any class implementingPipelineStage.processcomposes into anAnalyticsPipeline(§9) without any change toAnalyticsPipelineitself. - A new
AnalyticsCategory. A closed-enum change requiring governance (§31), mirroring thekpis.Aggregation/ontologyDelayCategoryclosed-enum precedent (spec 02 §10.9, §32; spec 05 §16.4).
33. Plugin Integration¶
Identical mechanism to every other extension point in the platform (Registry Framework spec 03), specialized for Analytics exactly as kpis._registry specializes it (spec 05 AD-KP-06):
# analytics/_registry.py
from mineproductivity.registry import Registry
REGISTRY: "Registry[str, type[AnalyticsModel]]" = Registry(name="analytics")
def register(cls: "type[AnalyticsModel]") -> "type[AnalyticsModel]":
"""Register cls into REGISTRY, keyed by cls.meta.code -- same shape
as kpis.register (spec 05), raising AnalyticsValidationError for an
empty code and AnalyticsVersionConflictError for a duplicate,
non-identical re-registration."""
from mineproductivity.analytics import register, AnalyticsMetadata, AnalyticsCategory, TrendModel
@register
class ExponentialTrendModel(TrendModel):
meta: ClassVar[AnalyticsMetadata] = AnalyticsMetadata(
code="TREND.Exponential", category=AnalyticsCategory.TREND,
description="Exponentially-weighted trend fit, more responsive to recent points.",
min_observations=5,
)
def _analyze(self, series, *, context): ...
[project.entry-points."mineproductivity.analytics"]
sitepack = "mineproductivity_sitepack.analytics"
Discovery uses registry.EntryPointDiscovery/registry.EntryPointSpec (spec 03) exactly as kpis and connectors already do - EntryPointSpec(group="mineproductivity.analytics", target_registry="analytics") - with the identical per-entry-point isolation guarantee (spec 03 §11: one broken plugin entry point is logged and skipped, never brings down discovery of the rest).
Versioning and re-registration. AnalyticsMetadata.version (§31) is a SemVer string with the same MAJOR/MINOR/PATCH meaning kpis.KPIMetadata.version already establishes (spec 05 §20): MAJOR for a breaking change to _analyze's output shape or semantics, MINOR for a non-breaking new capability (e.g. a new optional constructor parameter), PATCH for a defect fix with no output change. register raises AnalyticsVersionConflictError when a plugin attempts to re-register an existing code with metadata that differs from what is already registered - an AnalyticsModel code, like a KPI code, is a public contract once published (spec 05 §17's governance rule, applied identically here); it is never silently repointed to new behavior under the same code and version. A plugin retiring a strategy simply stops registering it; there is no AnalyticsCategory-level deprecation lifecycle equivalent to kpis.KPIStatus (spec 05 §10.2) in this initial design, since an unregistered AnalyticsModel carries no downstream dashboard/report dependency analogous to a published KPI code - this is recorded as a deliberately deferred question, not an oversight (§37).
34. Anti-Patterns¶
- ❌ Recomputing or reinterpreting a KPI's formula inside
analyticsinstead of callingkpis.KPIEngine.execute(). If a number is KPI-shaped, it comes fromkpis, full stop (§3.2). - ❌ Averaging already-computed
KPIResult.values across groups for aRATIO/AVERAGE-aggregation KPI instead of delegating back toKPIEngine.execute()over the combined scope (§10) - the exact "averaging a ratio" mistake spec 05 §32 already forbids at the period level, reintroduced one level up at the group level if this rule is not followed. - ❌ Defining a second time-window concept parallel to
kpis.Window/RollingWindow/CumulativeWindowinstead of importing and composing them (§11). - ❌ Defining a second benchmark-band schema parallel to
KPIMetadata.benchmark_bands/directioninstead of reading them viaRegistry.metadata_for(§13). - ❌ Shipping a concrete
ForecastingModel/AnomalyDetector/OutlierDetectorimplementation in this package. All three are interfaces only, by explicit design (§16, §18, §19); adding a concrete subclass here - even a "simple" one - is a scope violation of the "computation-only, no ML" boundary, not a convenience. - ❌ Importing
mineproductivity.connectorsfor anything beyond the permitted-but-unexercised layering rule (§5) - if anAnalyticsModelimplementation ever needs a vendor-specific field, that is a sign the computation belongs in (or should be normalized by)connectors/events, notanalytics. - ❌ A
_analyzeimplementation raising an exception for a legitimately un-analyzable input (too few observations) instead of returning anAnalyticsResultcarrying a warning (§8) - identical rule tokpis.BaseKPI.compute()(spec 05 §26). - ❌ Silently imputing missing values with anything beyond the four closed, deterministic
MissingDataPolicymembers (§26) - no policy member may call out to a predictive/statistical model to "guess" a missing value; that crosses into modeling this package does not do.
35. Testing Philosophy¶
Analytics' testing strategy follows the same layered structure kpis established (spec 05 §29), adapted to this package's shape:
- Unit tests per concrete model -
LinearTrendModel,RollingBaselineModel,BandBenchmarkModeleach tested against hand-computed reference values (a known slope for a synthetic linearly-increasing series; a known mean/std band for a synthetic series; a known band classification for aKPIResultwith hand-authoredbenchmark_bands). - Statistical primitive tests -
percentile,histogram,distribution,confidence_intervaleach tested against values independently verified by hand or against a reference statistical table, not merely re-asserting whatever the implementation itself computes. - Aggregation correctness tests -
AggregationEngine.reduce_kpi_resultstested against the exact spec 05 §13.3 worked example (A-shift/B-shift TPH), proving the combined-scope re-invocation path produces the correctly re-derived ratio, never the naive average, one layer above wherekpisitself already proves this. - Registry/discovery tests - mirror
tests/integration/test_registry_plugin_discovery.py's pattern exactly (a healthy and a broken fixtureAnalyticsModelplugin package, proving isolation), reusing the Registry Framework's own established fixture-plugin pattern rather than inventing a new one. - Interface-only module tests -
ForecastingModel,AnomalyDetector,OutlierDetectorare tested only for their ABC contract (attempting to instantiate the bare ABC raisesTypeError; a minimal test-only concrete subclass satisfies the abstract method signature) - there is no "correctness" test for an algorithm this package does not ship. - Streaming/incremental parity tests -
IncrementalAccumulator's streaming mean/variance compared againststatistics.describe()'s batch computation over the same full dataset, within floating-point tolerance, proving the O(1) incremental path and the batch path agree. - Data-quality scoring tests -
DataQualityScorer.scoretested against rows with known, hand-counted missing/invalid fields, asserting the exactcompleteness/validityfractions expected.
Package acceptance proofs, mirroring the shape of spec 05 §37's package-level acceptance criteria one layer up:
- No KPI-recomputation proof: a static analysis of every module in
src/mineproductivity/analytics/contains zero direct reads of raw event rows for the purpose of deriving a KPI-shaped number - every KPI-shaped value entering this package's tests arrives viakpis.KPIEngine.execute()or a fixture standing in for it. - Ratio-correctness-at-the-group-level proof: the
AggregationEngine.reduce_kpi_resultsregression test (§10) produces the correctly re-derived combined-scope ratio for a synthetic multi-siteRATIO-aggregation KPI, never the naive cross-site average. - Interface-purity proof:
ForecastingModel,AnomalyDetector, andOutlierDetector(§16, §18, §19) each have zero concrete, non-test subclasses anywhere insrc/mineproductivity/analytics/- mechanically verified the same way spec 05 §37.1 verifiesKPIEngineholds no metric-specific branches. - No architectural drift:
analyticsappears in the platform's dependency graph exactly per §5; the forbidden-imports check (no Foundation Layer file importsanalytics;analyticsimports nothing above itself) passes mechanically, identically in spirit to spec 05 §37.5. - Streaming/batch parity proof:
IncrementalAccumulator's streaming result andstatistics.describe()'s batch result agree within floating-point tolerance for every certification fixture (§29).
36. Performance Considerations¶
IncrementalAccumulator(§29) is the default posture for anything unbounded, not an optimization applied later -StreamingAnalyticsSessionnever buffers full history, andBatchAnalyticsRunneris expected to prefer it over a materialized in-memory list whenever the underlyingEventQuery/KPIResultsequence is large (mirrorskpis'Sequence[Mapping]"interface, not storage" distinction, spec 05 §23).- Delegation to
kpis.ExecutionBackend(spec 05 §10.9) for any vectorized, large-scale reduction insideAggregationEngine- Analytics does not introduce a second vectorization abstraction;AggregationEngineaccepts an optionalExecutionBackendand falls back to a plain-Python reference path only when none is supplied. - No resampling-based statistics by default (§24) - a bootstrap confidence interval is O(resamples × n); the closed-form
normal/tmethods are O(n) and are the default for exactly this reason, with resampling methods deferred to a documented future extension (§37) rather than silently made the default. - Column/field pruning mirrors
kpis' own performance rule (spec 05 §22):TimeSeries.from_event_queryrequests onlyvalue_field(plus the fields needed for scope/grouping), never the full envelope payload, when constructing a series destined only for statistical description.
37. Future Roadmap¶
This section describes extension points only for packages that do not yet exist and are explicitly out of scope for design in this document. No object model, API, or dependency for any of the following packages is proposed here.
flowchart BT
subgraph locked["Locked Foundation Layer + analytics (this spec)"]
CORE[core] --> ONT[ontology] --> EVT[events] --> KPI[kpis] --> AN[analytics]
end
subgraph future["Future, undesigned -- extension points only"]
DEC[decision]
TWIN[digital_twin]
OPT[optimization]
SIM[simulation]
AGENTS[agents]
end
AN -.stable AnalyticsResult family, §30.-> DEC
AN -.StreamingAnalyticsSession / IncrementalAccumulator, §27 §29.-> TWIN
AN -.Baseline / ConfidenceInterval, §15 §24.-> OPT
AN -.Baseline / ConfidenceInterval, §15 §24.-> SIM
AN -.ForecastingModel / AnomalyDetector / OutlierDetector ABCs, §16 §18 §19.-> AGENTS
decisionwill consumeAnalyticsResultobjects (particularlyTrendResult,BenchmarkResult, andBaseline) as evidence inputs to recommendation logic - the extension point this package exposes is simply that every result type is a stable, importablecore.BaseValueObject, serializable viacore.serializationexactly askpis.KPIResult/KPIMetadataalready are (spec 05 §21).digital_twinwill likely useStreamingAnalyticsSession/IncrementalAccumulator(§27, §29) as the statistical layer behind live simulated-state dashboards - the extension point is that both classes are usable standalone, without requiring a fullAnalyticsPipeline, for exactly this kind of lightweight embedding.optimizationandsimulationwill likely consumeBaseline/ConfidenceIntervaloutputs as inputs to constraint or scenario definitions - the extension point is that these result types carry noanalytics-internal state (they are frozen value objects), so they can be freely passed across a future package boundary without importinganalyticsinternals.- AI Agents (a future
agentspackage) will likely callAnalyticsPipeline.runor individualAnalyticsModel.analyzecalls the same way a human-authored report would - the extension point is that every public entry point in this specification (§7) already returns aResult-wrapped, structured, machine-readable object rather than a human-formatted string, exactly matching the KPI Engine spec's own rationale for whymine.describe(code)returnsKPIMetadatadirectly rather than prose (spec 05 §18) - "a KPI not in the registry does not exist to the system" applies equally to anAnalyticsModelnot inanalytics.REGISTRY. - Forecasting, anomaly detection, and outlier detection algorithms (§16, §18, §19) are the most immediate, already-designed-for extension points: their ABCs exist now specifically so that a future package (whether a first-party
optimization-adjacent effort or a third-party plugin) can implement against a fixed, stable contract without this specification needing revision.
None of the five items above constitutes a design for decision, digital_twin, optimization, simulation, or agents - each is restricted to naming which of this package's already-specified public types that future package is expected to consume.
End of Analytics Engine Design Specification. See docs/design/06_Analytics_Engine_Implementation_Checklist.md for the actionable implementation contract, and docs/adr/ADR-0006-Analytics-Engine.md for the architecture decision record governing this package's existence as a separate layer.