Decision Intelligence - Design Specification¶
| Document ID | AH-DS-07 |
| Package | mineproductivity.decision |
| 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); Analytics Engine spec 06 (LOCKED, analytics v0.8.0) |
| Author | Chief Software Architect, MineProductivity |
| Classification | Public - Open Source Design Documentation |
Document Control¶
Design specification only - no implementation. This document designs mineproductivity.decision, the second package built on top of the Foundation Layer, sitting directly above the now-locked analytics (spec 06). Nothing in this specification proposes, requires, or hints at a change to any file, public API, or dependency rule in core, events, ontology, registry, plugins, connectors, kpis, or analytics. Every object model, class name, and enum member cited from a lower package is taken verbatim from that package's own __init__.py public export list or its own governing design specification. Section numbering (1-36) mirrors the shape established by spec 06 (analytics): the same seven front-matter sections (Purpose through Public API), the same six closing sections (Extension Points through Future Roadmap), and, in between, twenty-three sections domain-specific to this package's own named responsibilities - the identical documentation structure, validation requirements, terminology-consistency discipline, cross-reference-validation rigor, and per-module seven-field package-structure treatment as spec 06, substituted only for this package's own package name, responsibilities, dependencies, and future-roadmap targets, per this task's explicit instruction to reuse that structure unchanged.
1. Purpose¶
Decision Intelligence answers the question analytics (spec 06) deliberately does not: given a trend, a benchmark classification, a baseline, or a confidence interval, what should the business actually do about it? analytics turns a correct KPI value into a statistical judgment (is it trending, is it normal, how does it compare). decision exists to take that judgment - or several judgments together, across metrics, sites, and time - and produce a recommended, ranked, explained, actionable decision: which situation warrants attention, what response is recommended, how urgently, and why. It is the platform's prescriptive layer, sitting directly above analytics' descriptive layer, and it holds no ingestion logic, no ontology or KPI definitions, no statistical computation, no optimization search, no simulation, and no machine learning - all of those already exist, one or more layers down, and are consumed rather than re-implemented (§3).
2. Business Objectives¶
- Turn a statistical judgment into a business action. A
TrendResultshowing a declining OEE trend, or aBenchmarkResultshowing a below-average fuel-per-tonne figure, is informative but silent on what to do next. Decision Intelligence exists to close that gap once, correctly, rather than asking every dashboard, report, or on-call engineer to invent their own escalation logic. - Make business policy an explicit, versioned, auditable artifact, not tribal knowledge encoded inconsistently across spreadsheets and shift-handover notes - the same "metadata/policy-as-object" discipline the platform already applies to KPIs (spec 05) and reuses here for business rules and thresholds (§12, §29).
- Make every recommendation explainable and traceable, so an operator, a manager, or a future AI agent (§36) can ask "why was I told to do this" and get a structured, evidence-linked answer (§17) rather than an opaque score.
- Separate "what is the best recommendation" from "what should be done first" - ranking and prioritization are related but distinct questions (§16, §20, §21), and conflating them has historically led to systems that surface the statistically most interesting finding rather than the operationally most urgent one.
- Support both scheduled (batch) and live (real-time) decision evaluation from one consistent object model, so a nightly site-review report and a live control-room alert are produced by the same rule engine, the same policies, and the same confidence scoring (§25, §26).
3. Architectural Principles¶
- Prescriptive, not descriptive or generative. Decision Intelligence decides what should happen given already-computed facts; it never computes the facts themselves (that is
analytics/kpis), never searches a solution space for an optimal plan (that isoptimization), never maintains simulated live state (that isdigital_twin), and never trains or infers with a machine-learning model (out of scope entirely, §4). - Consumption without redefinition. Decision Intelligence never recomputes a KPI value, a trend, a benchmark classification, or a data-quality score. Any time such a value is needed, it is read from
kpis.KPIResult/kpis.KPIMetadataoranalytics.AnalyticsResult(and its subclasses) directly - never re-derived. This is the single most important boundary in this specification (§9, §33). - Reuse over reinvention. Wherever a Foundation Layer package already defines a shape -
core.BaseSpecification(for rule composition, §10, §11),registry.Registry/registry.EntryPointDiscovery(for plugin discovery, §32),core.Result/core.Maybe,events.AsOf(already reserving ascenariofield for future what-if/scenario forking, §19) - Decision Intelligence composes it rather than inventing a parallel concept. Where an existing class's shape fits but its coupling does not (e.g.kpis.DependencyGraphis tightly coupled toRegistry[str, type[BaseKPI]]andKPIMetadata.dependencies, not to arbitrary action dependencies), this package implements its own narrow equivalent rather than forcing an ill-fitting reuse (§21, §33). - Interfaces before algorithms, where the algorithm is a modeling or causal-inference choice. Root-cause analysis and what-if simulation are each declared as a stable abstract contract now (§18, §19); no specific algorithm is chosen or shipped for either. This keeps the package strictly "business decision logic," not causal inference or simulation.
- Zero upward leakage. No lower package (
corethroughanalytics) importsdecision, mechanically enforced by the same AST-basedTestNoForbiddenDependenciespattern every existing package already uses. - Governance-weight where it belongs, not uniformly.
Policy/Threshold(§12, §13) are versioned, governed business artifacts - closer in spirit tokpis.KPIMetadata's governance weight than toanalytics.AnalyticsMetadata's lighter touch - because a business policy, once published, is exactly the kind of "public contract" spec 05 §17 already established for KPI codes.DecisionMetadata(§30) for a pluggable strategy, by contrast, stays as light asanalytics.AnalyticsMetadata, because a strategy implementation is a computational choice, not a business commitment. This package deliberately applies two different governance weights to two different kinds of artifact, rather than picking one weight for everything. - One extension mechanism, platform-wide. New rules, policies, decision strategies, ranking strategies, root-cause analyzers, or what-if engines are added exactly the way a new KPI, connector, ontology entity type, or Analytics model is added: subclass, register, discover via entry points (§31, §32). No bespoke Decision-Intelligence-specific plugin mechanism is invented.
4. Overall Architecture¶
Decision Intelligence occupies exactly one position in the platform's dependency chain - directly above analytics, and (as of this specification) at the top of the currently-implemented stack:
Everything below decision exists, from its point of view, to produce well-formed inputs: kpis.KPIEngine/kpis.KPIResult for already-correct metric values, analytics.AnalyticsResult (and subclasses) for already-correct statistical judgments over those values, events.EventStore/EventBus for the raw event trail a decision's audit record (§27) links back to, and ontology entity types for scoping a decision to a specific piece of equipment, pit, or shift. Everything above decision (digital_twin, simulation, optimization, visualization, agents - all future, undesigned packages, §36) exists to consume decision's outputs.
flowchart TB
subgraph inputs["Inputs (below decision in the stack)"]
KPI["kpis - KPIResult, KPIMetadata, KPIEngine"]
AN["analytics - AnalyticsResult family, AnalyticsPipeline"]
EVT["events - EventStore, EventBus, AsOf"]
ONT["ontology - entity types for scoping"]
end
subgraph decision["decision package - THIS SPEC"]
ABS[DecisionModel + DecisionContext]
PIPE[DecisionPipeline]
RULE[RuleEngine]
POL["Policy / Threshold"]
STRAT[DecisionStrategy]
REC["Recommendation generation / ranking / explanation"]
RC["RootCauseAnalyzer (interface only)"]
WI["WhatIfEngine (interface only)"]
PRI["ActionPrioritizer / ActionPlanner"]
ALERT[AlertGenerator]
SCORE["DecisionScorer / ConfidenceScorer"]
AUDIT[DecisionAuditTrail]
REG["DecisionRegistry (Registry Framework specialization)"]
end
subgraph consumers["Consumers (above decision -- future, undesigned)"]
TWIN[digital_twin]
SIM[simulation]
OPT[optimization]
VIZ[visualization]
AGENTS[agents]
end
KPI --> PIPE
AN --> PIPE
EVT --> AUDIT
ONT --> ABS
REG --> ABS
ABS --> STRAT
PIPE --> RULE
RULE --> POL
STRAT --> REC
REC --> PRI
PRI --> ALERT
STRAT --> SCORE
ABS --> RC
ABS --> WI
PIPE --> AUDIT
PIPE --> consumers
Decision Intelligence is deliberately not a second statistics engine competing with analytics, nor a second metric engine competing with kpis. It has no formula language, no percentile/histogram/trend-fitting logic of its own, and no KPI-code namespace - it is a business-rule and recommendation layer over facts kpis and analytics already produced.
5. Dependency Graph¶
Permitted imports (platform layering rule, verbatim from this package's brief): decision may import mineproductivity.core, mineproductivity.events, mineproductivity.ontology, mineproductivity.registry, mineproductivity.plugins, mineproductivity.connectors, mineproductivity.kpis, and mineproductivity.analytics, and nothing else.
Actually exercised by this design: core (value objects, Result/Maybe, BaseSpecification for rule composition, exceptions), events (EventStore/EventBus for audit-trail linkage and real-time evaluation, AsOf for what-if context framing), ontology (entity types for scoping a DecisionContext), registry/plugins (the DecisionRegistry specialization and entry-point discovery, §32), kpis (KPIResult, KPIMetadata, Direction), and analytics (AnalyticsResult and every subclass, AnalyticsPipeline, TimeSeries, DataQualityScore in particular, consumed directly by confidence scoring, §24). connectors is a permitted import under the platform-wide layering rule but, exactly as in analytics (spec 06 §5), is not exercised by any class in this specification - Decision Intelligence operates on already-computed KPIResult/AnalyticsResult objects, never on a vendor-specific wire format (§33).
graph TD
core --> decision
ontology --> decision
events --> decision
registry --> decision
plugins --> decision
connectors -.permitted, unused.-> decision
kpis --> decision
analytics --> decision
decision --> digital_twin["digital_twin (future)"]
decision --> simulation["simulation (future)"]
decision --> optimization["optimization (future)"]
decision --> visualization["visualization (future)"]
decision --> agents["agents (future)"]
Depended on by (future, undesigned): digital_twin, simulation, optimization, visualization, agents.
Forbidden, mechanically enforced:
- decision MUST NOT be imported by core, ontology, events, registry, plugins, connectors, kpis, or analytics - checked by an AST walk exactly like every existing package's TestNoForbiddenDependencies test.
- decision MUST NOT import digital_twin, simulation, optimization, visualization, or agents - those are all strictly above it and, as of this specification, do not yet exist.
- No cycle exists or is introduced: core → ontology → events → kpis → analytics → decision is a strict total order for every symbol this package uses.
6. Package Structure¶
src/mineproductivity/decision/
├── __init__.py # public API surface (§7)
├── abstractions.py # DecisionModel (ABC), DecisionContext
├── metadata.py # DecisionMetadata, DecisionCategory
├── result.py # DecisionResult (base) and every concrete result dataclass
├── pipeline.py # DecisionPipeline, PipelineStage (ABC)
├── rules.py # Rule (type alias over core.BaseSpecification), RuleEngine, RuleEngineStage
├── policy.py # Policy, DecisionStatus (policy lifecycle)
├── thresholds.py # Threshold, ThresholdBreach
├── strategy.py # DecisionStrategy (ABC), ThresholdDecisionStrategy
├── recommendation.py # Recommendation, recommendation-producing helpers
├── ranking.py # RankingStrategy (ABC), WeightedScoreRanking
├── explanation.py # ExplanationBuilder, ExplanationStage, Explanation
├── root_cause.py # RootCauseAnalyzer (ABC) -- interface only, §18
├── what_if.py # WhatIfEngine (ABC) -- interface only, §19
├── prioritization.py # ActionPrioritizer, ActionPriority
├── planning.py # ActionPlanner, ActionPlan
├── alerting.py # AlertGenerator, Alert
├── scoring.py # DecisionScorer, DecisionScore, ConfidenceScorer, ConfidenceScore
├── realtime.py # RealTimeDecisionSession
├── batch.py # BatchDecisionRunner
├── audit.py # DecisionAuditTrail, DecisionAuditEntry
├── _registry.py # REGISTRY, register (Registry Framework specialization)
├── exceptions.py
└── README.md
Twenty-two implementation modules plus __init__.py and README.md - comparable in scale to analytics (spec 06 §6: twenty-one implementation modules). Every module below is specified against the same seven fields spec 06 used: Purpose, Responsibilities, Public Classes, Public Functions, Public API, Dependencies, and Extension Points.
abstractions.py¶
- Purpose: the "Decision-as-object" root, mirroring
analytics.AnalyticsModel's shape two layers up fromkpis.BaseKPI. - Responsibilities: define the one method every registrable decision strategy implements (
_decide); define the non-overridden orchestration wrapper (decide); bundle the collaborators (EventStore, entity scope,AsOf) a concrete strategy may need. - Public Classes:
DecisionModel(ABC),DecisionContext. - Public Functions: None.
- Public API:
DecisionModel,DecisionContext. - Dependencies:
core(Result),events(EventStore,AsOf),kpis(KPIResult),analytics(AnalyticsResult). - Extension Points: every category base in
strategy.py/ranking.py/root_cause.py/what_if.pysubclassesDecisionModel.
metadata.py¶
- Purpose: the minimal registration schema for a discoverable
DecisionModel(§30). - Responsibilities: carry just enough structured information for registry introspection and entry-point discovery; enforce the closed
DecisionCategorynamespace. - Public Classes:
DecisionMetadata,DecisionCategory(enum). - Public Functions: None.
- Public API:
DecisionMetadata,DecisionCategory. - Dependencies:
core(BaseMetadata,ValidationError). - Extension Points: a new
DecisionCategorymember is a closed-enum, governance-reviewed change, mirroringanalytics.AnalyticsCategory's closed-enum rule (spec 06 §31).
result.py¶
- Purpose: every concrete decision output type (§28).
- Responsibilities: define one shared envelope (
DecisionResult) and the full family of concrete results built on it. - Public Classes:
DecisionResult,Recommendation,RankedRecommendation,Explanation,Alert,ActionPlan,ActionPriority,DecisionScore,ConfidenceScore,RootCauseResult,WhatIfResult,ThresholdBreach. - Public Functions: None.
- Public API: every class listed above.
- Dependencies:
core(BaseValueObject),analytics(DataQualityScore, referenced byConfidenceScore). - Extension Points: a new concrete result type is added only alongside a new category of
DecisionModel(§31).
pipeline.py¶
- Purpose: compose ordered decision-evaluation steps over one
DecisionContext(§9). - Responsibilities: run stages in order (context assembly → rule evaluation → recommendation generation → ranking → explanation); enforce that the final stage yields a
DecisionResult; hold no strategy-specific branching, mirroringanalytics.AnalyticsPipeline's "holds no model logic" invariant one layer up (spec 06 §9). - Public Classes:
PipelineStage(ABC),DecisionPipeline,ModelStage. - Public Functions: None.
- Public API:
DecisionPipeline,PipelineStage,ModelStage. - Dependencies:
core(Result), this package's ownresult.py/abstractions.py. - Extension Points: a new pipeline stage is any
PipelineStageimplementation; no change toDecisionPipelineitself is ever required (§9, §31).
rules.py¶
- Purpose: rule evaluation (§10) via composition, not a bespoke rule language.
- Responsibilities: define
Ruleas a type alias overcore.BaseSpecification[DecisionContext](exactly the patternevents.EventFilter = BaseSpecification[EventEnvelope[Any]]already established); evaluate a named mapping of rules against oneDecisionContext, isolating one rule's evaluation failure from the rest; provide thePipelineStagewrapper (RuleEngineStage) that letsRuleEnginecompose directly into aDecisionPipeline(§9), mirroringanalytics.quality.DataQualityStage's relationship toDataQualityScorer(spec 06 §6). - Public Classes:
RuleEngine,RuleEngineStage. - Public Functions: None (
Ruleis atypealias, not a class). - Public API:
Rule,RuleEngine,RuleEngineStage. - Dependencies:
core(BaseSpecification,AndSpecification,OrSpecification,NotSpecification,PredicateSpecification- all reused directly for rule composition, never re-implemented),pipeline.py(PipelineStage, whichRuleEngineStageimplements). - Extension Points: a new rule is authored by composing
PredicateSpecification/AndSpecification/OrSpecification/NotSpecificationoverDecisionContextfields - no change toRuleEngineitself is ever required.
policy.py¶
- Purpose: business policies as versioned, governed artifacts (§12).
- Responsibilities: bundle a named set of
Rules, theThresholds they reference, and whichDecisionStrategy/category they activate; carry a lifecycle status. - Public Classes:
Policy,DecisionStatus(enum:Proposed/Active/Superseded/Retired, mirroringkpis.KPIStatus, spec 05 §10.2). - Public Functions: None.
- Public API:
Policy,DecisionStatus. - Dependencies:
core(BaseValueObject,ValidationError),rules.py(Rule),thresholds.py(Threshold). - Extension Points: a new
Policyis authored and registered exactly like a new KPI or Analytics model (§31, §32); an existingPolicyis never edited in place - a changed policy is a new version with the old one moved toSuperseded(§13, §29).
thresholds.py¶
- Purpose: the declarative limit values a
Policy's rules reference (§13). - Responsibilities: represent one comparator/limit pair (e.g.
OEE < 0.65) and the breach event it produces when aDecisionContext's relevant field crosses it. - Public Classes:
Threshold. - Public Functions: None (
ThresholdBreachis a result type, defined inresult.py). - Public API:
Threshold. - Dependencies:
core(BaseValueObject,ValidationError). - Extension Points: a new comparator kind (beyond
</<=/>/>=/==/!=) is an additive, reviewed change toThreshold's comparator literal.
strategy.py¶
- Purpose: the pluggable "how do we decide" abstraction (§14).
- Responsibilities: category base for decision-making strategies; the default, concrete, rule/threshold-driven strategy.
- Public Classes:
DecisionStrategy(ABC),ThresholdDecisionStrategy(concrete, ships by default). - Public Functions: None.
- Public API:
DecisionStrategy,ThresholdDecisionStrategy. - Dependencies:
abstractions.py(DecisionModel),rules.py,policy.py,thresholds.py,recommendation.py. - Extension Points: a new concrete
DecisionStrategysubclass (§31.1) - e.g. a future strategy that weighs multiple policies against each other.
recommendation.py¶
- Purpose: recommendation generation (§15).
- Responsibilities: produce one or more
Recommendationresult objects from aDecisionStrategy's evaluation of aDecisionContextagainst an activePolicy. - Public Classes: None (the
Recommendationtype lives inresult.py; this module holds the generation logic as methods onThresholdDecisionStrategy, not a separate public class). - Public Functions: None.
- Public API: None directly (see
strategy.py/result.py). - Dependencies:
result.py,policy.py. - Extension Points: none beyond a new
DecisionStrategy(§31.1) - this module is not itself a registry-pluggable extension point.
ranking.py¶
- Purpose: recommendation ranking (§16) - a distinct question from prioritization (§20).
- Responsibilities: order a
Sequence[Recommendation]into aSequence[RankedRecommendation]usingDecisionScore. - Public Classes:
RankingStrategy(ABC),WeightedScoreRanking(concrete, ships by default). - Public Functions: None.
- Public API:
RankingStrategy,WeightedScoreRanking. - Dependencies:
abstractions.py,scoring.py(DecisionScore),result.py. - Extension Points: a new concrete
RankingStrategysubclass (§31.1).
explanation.py¶
- Purpose: recommendation explanations (§17).
- Responsibilities: walk a
Recommendation's provenance (whichRules fired, whichAnalyticsResult/KPIResultfed it) and produce a structuredExplanation; provide thePipelineStagewrapper (ExplanationStage) that attaches anExplanationto everyRecommendation/RankedRecommendationalready present in aDecisionContextby the time it runs as the pipeline's terminal stage. - Public Classes:
ExplanationBuilder,ExplanationStage. - Public Functions: None.
- Public API:
ExplanationBuilder,ExplanationStage. - Dependencies:
result.py(Explanation,Recommendation),pipeline.py(PipelineStage, whichExplanationStageimplements). - Extension Points: none for
ExplanationBuilderitself - it is a concrete, non-pluggable utility; a new kind of evidence to cite is added to its traversal logic without changing its public signature.
root_cause.py¶
- Purpose: interface-only extension point (§18) - no concrete implementation.
- Responsibilities: define a stable abstract contract a future plugin implements against.
- Public Classes:
RootCauseAnalyzer(ABC). - Public Functions: None.
- Public API:
RootCauseAnalyzer. - Dependencies:
abstractions.py,result.py(RootCauseResult, for the return-type annotation only). - Extension Points: the entire purpose of this module - a concrete subclass is a first-class extension (§31.2), never added inside this module itself (§33).
what_if.py¶
- Purpose: interface-only extension point (§19) - no concrete implementation.
- Responsibilities: define a stable abstract contract for hypothetical-scenario evaluation, deliberately reusing
events.AsOf's already-reservedscenariofield rather than inventing a second scenario concept. - Public Classes:
WhatIfEngine(ABC). - Public Functions: None.
- Public API:
WhatIfEngine. - Dependencies:
abstractions.py,events(AsOf),result.py(WhatIfResult). - Extension Points: same as
root_cause.py- interface only (§31.2, §33).
prioritization.py¶
- Purpose: action prioritization (§20) - distinct from ranking (§16).
- Responsibilities: given a
Sequence[RankedRecommendation], assign each anActionPriorityreflecting urgency/impact/effort under limited execution capacity. - Public Classes:
ActionPrioritizer. - Public Functions: None.
- Public API:
ActionPrioritizer. - Dependencies:
result.py(RankedRecommendation,ActionPriority). - Extension Points: a new prioritization factor (beyond urgency/impact/effort) is an additive field, reviewed the same way a new
Thresholdcomparator would be.
planning.py¶
- Purpose: action planning (§21) - sequencing prioritized actions respecting dependencies.
- Responsibilities: topologically order a set of actions with declared dependencies into an
ActionPlan, using a narrow, self-contained ordering implementation rather than reusingkpis.DependencyGraph(which is coupled toRegistry[str, type[BaseKPI]]/KPIMetadata.dependencies, not to arbitrary actions, §3.3, §33). - Public Classes:
ActionPlanner. - Public Functions: None.
- Public API:
ActionPlanner. - Dependencies:
result.py(ActionPlan,ActionPriority),core(Result). - Extension Points: none - this module is deliberately closed and narrow; a genuinely more general DAG-ordering utility, if ever needed platform-wide, belongs in
core, not duplicated here.
alerting.py¶
- Purpose: alert generation (§22).
- Responsibilities: produce an
Alertfrom aThresholdBreachor a high-severityRecommendation. - Public Classes:
AlertGenerator. - Public Functions: None.
- Public API:
AlertGenerator. - Dependencies:
result.py(Alert,ThresholdBreach),thresholds.py. - Extension Points: a new alert severity/channel classification is an additive, reviewed change.
scoring.py¶
- Purpose: decision scoring and confidence scoring (§23, §24).
- Responsibilities: compute the numeric weight (
DecisionScore) backing ranking; compute the trust weight (ConfidenceScore) backing how strongly a recommendation should be acted on, derived fromanalytics.DataQualityScoreplus rule-evidence strength - never recomputed as a duplicate of Analytics' own data-quality scoring (§3.2, §24, §33). - Public Classes:
DecisionScorer,ConfidenceScorer. - Public Functions: None.
- Public API:
DecisionScorer,ConfidenceScorer. - Dependencies:
analytics(DataQualityScore),result.py(DecisionScore,ConfidenceScore). - Extension Points: a new scoring factor is an additive, reviewed change to either scorer's inputs, not a new parallel scorer class.
realtime.py / batch.py¶
- Purpose: the two execution modes (§25, §26).
- Responsibilities:
realtime.pymaintains a long-livedevents.EventBussubscription, refreshing the relevantKPIEngine/AnalyticsPipelineoutputs and re-evaluatingRuleEngineon each new relevant event;batch.pyruns oneDecisionPipelineonce over a boundedDecisionContext, for scheduled, report-style evaluation. - Public Classes:
RealTimeDecisionSession(realtime.py),BatchDecisionRunner(batch.py). - Public Functions: None.
- Public API: both classes listed above.
- Dependencies:
events(EventBus,Subscription),kpis(KPIEngine),analytics(BatchAnalyticsRunner,AnalyticsPipeline),pipeline.py. - Extension Points: a new execution mode (e.g. a scheduled batch-of-batches runner) is a new class composing
DecisionPipelinethe same wayBatchDecisionRunnerdoes.
audit.py¶
- Purpose: the decision audit trail (§28).
- Responsibilities: record every
Recommendation/Alertever generated, with full provenance back to theRules that fired, theAnalyticsResult/KPIResults that fed it, and (where available) the originatingEventIDs. - Public Classes:
DecisionAuditTrail,DecisionAuditEntry. - Public Functions: None.
- Public API: both classes listed above.
- Dependencies:
core(BaseValueObject,serialization),events(EventID). - Extension Points: a new audit-entry field is an additive, reviewed change; the audit trail itself is never bypassed by any pipeline stage (§33).
_registry.py¶
- Purpose: the
DecisionModelregistry, following the exact patternanalytics._registry/kpis._registryestablished (spec 06 §33, spec 05 AD-KP-06) rather than reimplementing registration. - Responsibilities: hold the process-wide
Registry[str, type[DecisionModel]]instance; validate a non-emptycode; reject a duplicate, non-identical re-registration. - Public Classes: None.
- Public Functions:
register. - Public API:
REGISTRY,register. - Dependencies:
registry(Registry),metadata.py,exceptions.py. - Extension Points: none within this module itself - it is the extension mechanism (§32) other modules and third-party plugins use.
exceptions.py¶
- Purpose: the package's exception hierarchy, used throughout §8-§33, mirroring
analytics.exceptions'/kpis.exceptions' single-module convention (spec 06 §6, spec 05 §6). - Responsibilities: define every raised error type this package's public API can produce.
- Public Classes:
class DecisionValidationError(ValidationError): """A DecisionMetadata, Policy, or Threshold failed validation (§30, §12, §13) -- e.g. an empty code, a Policy with zero rules, or an empty Threshold.field.""" class NoApplicablePolicyError(NotFoundError): """Raised only where a caller explicitly requests raising behavior instead of DecisionModel.decide()'s default empty- recommendation result (§8) -- e.g. a BatchDecisionRunner configured to treat 'no policy matched' as a hard failure for a specific report.""" class DecisionModelNotFoundError(NotFoundError): """REGISTRY.get(code) found no registered DecisionModel for `code` (§32).""" class DecisionVersionConflictError(RegistrationError): """A plugin attempted to re-register an existing DecisionModel code with materially different metadata without a version bump (§32), mirroring kpis.KPIVersionConflictError (spec 05 §26) and analytics.AnalyticsVersionConflictError (spec 06 §6).""" class PolicyConflictError(RegistrationError): """A plugin or governance action attempted to re-register an existing, Active Policy code with different rules/thresholds without a version bump and a Superseded transition for the prior version (§12, §29) -- the Policy-layer analogue of DecisionVersionConflictError, since a Policy is a separate governed artifact from a DecisionModel implementation (§3.6).""" - Public Functions: None.
- Public API: all five exception classes listed above.
- Dependencies:
core(ValidationError,NotFoundError),registry(RegistrationError) - each exception here subclasses the matchingcoreorregistryexception. - 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
DecisionSimulationErrorexists yet, sinceWhatIfEngineships no concrete implementation to fail, §19).
7. Public API¶
from mineproductivity.decision import (
# Abstractions
DecisionModel, DecisionContext,
# Metadata
DecisionMetadata, DecisionCategory,
# Result models
DecisionResult, Recommendation, RankedRecommendation, Explanation, Alert,
ActionPlan, ActionPriority, DecisionScore, ConfidenceScore,
RootCauseResult, WhatIfResult, ThresholdBreach,
# Pipelines
DecisionPipeline, PipelineStage, ModelStage,
# Rules, policies, thresholds
Rule, RuleEngine, RuleEngineStage, Policy, DecisionStatus, Threshold,
# Strategies
DecisionStrategy, ThresholdDecisionStrategy,
RankingStrategy, WeightedScoreRanking,
# Explanation
ExplanationBuilder, ExplanationStage,
# Interfaces only -- no concrete implementation (§18, §19)
RootCauseAnalyzer, WhatIfEngine,
# Action prioritization and planning
ActionPrioritizer, ActionPlanner,
# Alerting and scoring
AlertGenerator, DecisionScorer, ConfidenceScorer,
# Execution modes
RealTimeDecisionSession, BatchDecisionRunner,
# Audit
DecisionAuditTrail, DecisionAuditEntry,
# Registry (Registry Framework specialization)
register, REGISTRY,
# Exceptions
DecisionValidationError, NoApplicablePolicyError,
DecisionModelNotFoundError, DecisionVersionConflictError, PolicyConflictError,
)
Every name above is intended to be stable once implementation begins, per the same "prefer fewer, carefully designed interfaces" discipline spec 06 §7 already applied - no speculative "maybe useful" symbol is included; each name maps directly to one of the sections below.
8. Decision Abstractions¶
class DecisionContext:
"""Bundles the collaborators and scope a DecisionModel needs -- the
decision-layer counterpart to analytics.AnalyticsContext (spec 06
§8), one layer up. Carries the KPIResult/AnalyticsResult evidence
already gathered, the ontology-scoped entity the decision concerns
(e.g. a specific RigidHaulTruck or Pit), and optionally an AsOf
(events spec 01) framing a point-in-time or hypothetical-scenario
view for root-cause/what-if use (§18, §19)."""
def __init__(
self,
*,
kpi_results: "Sequence[KPIResult]",
analytics_results: "Sequence[AnalyticsResult]",
scope: "Mapping[str, str]",
event_store: "EventStore | None" = None,
as_of: "AsOf | None" = None,
) -> None: ...
class DecisionModel(ABC):
"""The root of every registrable decision strategy -- 'Decision-as-
object,' the direct counterpart of kpis.BaseKPI and
analytics.AnalyticsModel, one and two layers down respectively. A
concrete leaf declares `meta: ClassVar[DecisionMetadata]` and
implements `_decide`; everything else (input validation, result
envelope wrapping) is inherited."""
meta: ClassVar[DecisionMetadata]
@abstractmethod
def _decide(self, context: DecisionContext) -> DecisionResult:
"""Pure function: a DecisionContext in, one DecisionResult out.
MUST NOT raise for a legitimately no-recommendation input (no
policy applies, no threshold breached) -- return a
DecisionResult carrying zero recommendations and, optionally, a
warning, exactly the 'qualify, don't coerce' rule kpis.BaseKPI
and analytics.AnalyticsModel already established."""
def decide(self, context: DecisionContext) -> DecisionResult:
"""Non-overridden orchestration: validates `context` carries at
least one KPIResult or AnalyticsResult to reason over, then
calls `_decide`."""
if not context.kpi_results and not context.analytics_results:
return DecisionResult(
model_code=self.meta.code,
warnings=("no evidence in context: at least one KPIResult or AnalyticsResult required",),
)
return self._decide(context)
classDiagram
class DecisionModel {
<<abstract>>
+meta: ClassVar~DecisionMetadata~
+decide(context) DecisionResult
#_decide(context) DecisionResult
}
class DecisionContext
class DecisionMetadata
class DecisionResult
DecisionModel --> DecisionMetadata
DecisionModel --> DecisionResult : produces
DecisionModel ..> DecisionContext : uses
DecisionModel <|-- DecisionStrategy
DecisionModel <|-- RankingStrategy
DecisionModel <|-- RootCauseAnalyzer
DecisionModel <|-- WhatIfEngine
DecisionStrategy <|-- ThresholdDecisionStrategy
RankingStrategy <|-- WeightedScoreRanking
DecisionResult <|-- Recommendation
DecisionResult <|-- RankedRecommendation
DecisionResult <|-- RootCauseResult
DecisionResult <|-- WhatIfResult
class Registry~TKey,TItem~
Registry <|.. DecisionRegistryShape : specialized as REGISTRY
Thread safety and statelessness. Exactly like kpis.BaseKPI and analytics.AnalyticsModel (spec 05 §24, spec 06 §8), every DecisionModel subclass MUST be stateless across decide() calls, so a single instance is safe to share and invoke concurrently. decision.REGISTRY inherits Registry's own thread-safety contract (spec 03 §24). DecisionAuditTrail (§27), like analytics.IncrementalAccumulator (spec 06 §29), is the one deliberately mutable component in this package and carries its own, separately-specified thread-safety contract (§27), not assumed from this section.
Who assembles a DecisionContext. This specification deliberately supports two distinct assembly patterns rather than picking one: for BatchDecisionRunner (§26) and for direct DecisionPipeline usage (§9's worked example), the caller assembles a DecisionContext explicitly - calling kpis.KPIEngine.execute() and analytics.BatchAnalyticsRunner.run() itself, then constructing the DecisionContext from the results, before ever invoking the pipeline. For RealTimeDecisionSession (§25), the session itself performs that assembly internally, on every relevant new event, because no caller is present at each triggering moment to do it by hand. Both patterns call exactly the same kpis/analytics public entry points to gather evidence (§3.2) - the only difference is who holds the reference to KPIEngine/BatchAnalyticsRunner and when the assembly call happens, not what gets called or how the resulting DecisionContext is shaped. A future agents consumer (§36) is expected to use the caller-assembles pattern, since an agent already has its own control loop deciding when to gather fresh evidence.
9. Decision Pipelines¶
A DecisionPipeline is an ordered sequence of PipelineStages applied to one DecisionContext - the decision-layer equivalent of analytics.AnalyticsPipeline (spec 06 §9), one layer up. A typical pipeline: gather evidence (KPI/Analytics results already computed elsewhere) → evaluate rules (§10) against the active Policy (§12) → generate recommendations (§15) → rank them (§16) → explain them (§17).
class PipelineStage(ABC):
"""One step in a DecisionPipeline. Stateless and composable -- a new
stage never requires changing DecisionPipeline itself (§31)."""
@abstractmethod
def process(self, context: DecisionContext) -> DecisionContext:
"""Transform one DecisionContext into another (e.g. attaching
rule-evaluation results) -- OR, for a terminal stage, wrap the
context into a DecisionResult (see `ModelStage` below)."""
class ModelStage(PipelineStage):
"""A terminal stage that hands the (by now rule-evaluated) context
to one DecisionModel and yields its DecisionResult."""
def __init__(self, model: DecisionModel) -> None: ...
class DecisionPipeline:
"""An ordered Sequence[PipelineStage], run in order over one input
DecisionContext."""
def __init__(self, stages: "Sequence[PipelineStage]") -> None: ...
def run(self, context: DecisionContext) -> Result[DecisionResult]:
"""Runs every stage in order; the last stage MUST yield a
DecisionResult or `run` returns
Result.err(DecisionValidationError(...))."""
sequenceDiagram
participant App
participant Pipe as DecisionPipeline
participant Rules as RuleEngine (stage)
participant Strat as ModelStage(ThresholdDecisionStrategy)
participant Rank as ModelStage(WeightedScoreRanking)
participant Expl as ExplanationBuilder (stage)
App->>Pipe: run(context)
Pipe->>Rules: process(context)
Rules-->>Pipe: context + triggered rule names
Pipe->>Strat: process(context)
Strat-->>Pipe: context + Recommendation(s)
Pipe->>Rank: process(context)
Rank-->>Pipe: context + RankedRecommendation(s)
Pipe->>Expl: process(context)
Expl-->>Pipe: DecisionResult with Explanation attached
Pipe-->>App: Result.ok(DecisionResult)
Worked example. Illustrative of the intended end-to-end shape once implemented - evaluating a fleet-availability policy against a fresh AnalyticsResult:
from mineproductivity.kpis import KPIEngine, REGISTRY as KPI_REGISTRY
from mineproductivity.analytics import BatchAnalyticsRunner, AnalyticsPipeline, ModelStage as AnModelStage, LinearTrendModel
from mineproductivity.decision import (
DecisionContext, DecisionPipeline, ModelStage, RuleEngineStage,
ThresholdDecisionStrategy, WeightedScoreRanking, ExplanationStage,
)
kpi_engine = KPIEngine(store=event_store, registry=KPI_REGISTRY, backend=active_backend, cache=result_cache)
oee_result = kpi_engine.execute("UTIL.OEE", window="day", scope={"day": today, "pit": "north"}).unwrap()
trend_runner = BatchAnalyticsRunner(
pipeline=AnalyticsPipeline(stages=(AnModelStage(LinearTrendModel()),)),
context=analytics_context,
)
trend_result = trend_runner.run(oee_series).unwrap()
context = DecisionContext(
kpi_results=(oee_result,), analytics_results=(trend_result,),
scope={"pit": "north", "day": today}, event_store=event_store,
)
pipeline = DecisionPipeline(stages=(
RuleEngineStage(policy=fleet_availability_policy),
ModelStage(ThresholdDecisionStrategy()),
ModelStage(WeightedScoreRanking()),
ExplanationStage(),
))
result = pipeline.run(context)
if result.is_ok:
decision = result.unwrap()
for ranked in decision.ranked_recommendations:
print(ranked.recommendation.summary, ranked.score.value, ranked.recommendation.explanation.premises)
This example deliberately reuses kpis.KPIEngine.execute() and analytics.BatchAnalyticsRunner for every fact the pipeline reasons over (§3.2) - nothing in decision recomputes UTIL.OEE or its trend; the pipeline's only job is to decide what to do about already-correct facts.
10. Rule Engine¶
type Rule = BaseSpecification["DecisionContext"]
"""Reuses core.BaseSpecification (&/|/~ composable) exactly as
events.EventFilter already does (events spec 01) -- rule evaluation is
never a bespoke DSL in this platform."""
class RuleEngine:
"""Evaluates a named mapping of Rules against one DecisionContext,
isolating one rule's evaluation failure from the rest -- the
decision-layer analogue of registry.EntryPointDiscovery's per-entry-
point isolation guarantee (spec 03 §11), applied to rule
predicates instead of plugin imports."""
def evaluate(self, rules: "Mapping[str, Rule]", context: DecisionContext) -> "Sequence[str]":
"""Returns the names of every rule satisfied by `context`, in a
stable (sorted) order. A single rule raising during
`is_satisfied_by` is caught, logged, and excluded from the
result -- never allowed to prevent evaluation of the remaining
rules."""
A Rule is composed the same way any core.BaseSpecification is composed - PredicateSpecification(lambda ctx: ctx.kpi_results[0].value < 0.65) & PredicateSpecification(lambda ctx: "north" in ctx.scope.get("pit", "")) - using AndSpecification/OrSpecification/NotSpecification for combination. No new boolean-expression language is introduced anywhere in this package. A Threshold (§13) is not itself a Rule - it is the declarative data a Policy author uses to construct a PredicateSpecification-based Rule (e.g. Threshold(field="value", comparator="<", limit=0.65) compiles to the predicate shown above); this keeps the versioned, governed part of a Policy (its Threshold limit values) separable from the Rule predicate wiring, which changes only when the underlying DecisionContext shape itself changes.
class RuleEngineStage(PipelineStage):
"""The PipelineStage wrapper composing RuleEngine into a
DecisionPipeline -- attaches the triggered rule names to the
DecisionContext passed downstream, so ModelStage(ThresholdDecisionStrategy())
(§14) never has to re-run rule evaluation itself."""
def __init__(self, *, policy: "Policy") -> None: ...
def process(self, context: DecisionContext) -> DecisionContext: ...
11. Rule Composition¶
Composability is inherited entirely from core.specification (AndSpecification, OrSpecification, NotSpecification, PredicateSpecification) - this section exists to record, normatively, that Decision Intelligence adds zero new composition operators. A rule authored for one Policy is a plain Rule value and can be reused, negated, or combined into a different Policy's rule set with the same &/|/~ operators any core.BaseSpecification already supports, without this package defining a second composition mechanism.
12. Business Policies¶
class DecisionStatus(Enum):
"""The Policy lifecycle -- mirrors kpis.KPIStatus (spec 05 §10.2)
exactly, applied here to governed business artifacts rather than
to individual computed results."""
PROPOSED = "proposed"
ACTIVE = "active"
SUPERSEDED = "superseded"
RETIRED = "retired"
@dataclass(frozen=True, slots=True)
class Policy(BaseValueObject):
"""A named, versioned bundle of Rules, the Thresholds they
reference, and which DecisionStrategy/category they activate --
the business-policy equivalent of KPIMetadata's governance weight
(§3.6)."""
code: str # e.g. "AVAIL.LowFleetAvailability"
version: str = field(default="1.0.0", kw_only=True)
status: DecisionStatus = field(default_factory=lambda: DecisionStatus.PROPOSED, kw_only=True)
rules: "Mapping[str, Rule]" = field(kw_only=True)
thresholds: "Mapping[str, Threshold]" = field(default_factory=dict, kw_only=True)
strategy_code: str = field(kw_only=True) # DecisionMetadata.code of the DecisionStrategy to invoke
def validate(self) -> None:
if not self.code.strip():
raise ValidationError("Policy.code must not be empty")
if not self.rules:
raise ValidationError(f"{self.code}: a Policy must declare at least one rule")
A Policy, once Active, is a public contract exactly as a KPI code is (spec 05 §17's governance rule, reapplied at this layer, §29): it is never edited in place. A changed policy is published as a new version; the prior version transitions to Superseded, never silently repointed - enforced by PolicyConflictError (§6 exceptions.py) raised when a plugin or governance action attempts to re-register an existing, Active policy code with materially different rules/thresholds without a version bump.
Policy.strategy_code names the DecisionMetadata.code of the DecisionStrategy (§14) that should evaluate this policy - a Policy is data describing which rules matter and what to do when they fire, while the DecisionStrategy it names is the code implementing how to turn a rule match into a Recommendation. This separation means a site can author and publish new policies (new rule/threshold combinations against the existing ThresholdDecisionStrategy) through ordinary governance review alone, without any code change or redeployment - the same "policy-as-data, strategy-as-code" split kpis already relies on between KPIMetadata (data) and BaseKPI._compute (code, spec 05 §10.1, §10.3). A Policy referencing a strategy_code for which no DecisionModel is currently registered fails validation at activation time (DecisionModelNotFoundError, §6), not silently at first evaluation.
13. Operational Thresholds¶
@dataclass(frozen=True, slots=True)
class Threshold(BaseValueObject):
"""One declarative limit a Rule references, e.g. Threshold(
field="value", comparator="<", limit=0.65) checked against the
relevant KPIResult/AnalyticsResult field named by `field`."""
field: str
comparator: "Literal['<', '<=', '>', '>=', '==', '!=']"
limit: float
def validate(self) -> None:
if not self.field.strip():
raise ValidationError("Threshold.field must not be empty")
A Threshold is deliberately data, not code - a Policy can be updated to reference a new limit value through ordinary version governance (§12) without touching any Rule's predicate logic, and a ThresholdBreach (§28) records exactly which threshold, at what observed value, was crossed, for the audit trail (§27).
Threshold.field is resolved against the specific KPIResult/AnalyticsResult a Policy's author designates when authoring the Threshold (e.g. "value" for a bare KPIResult.value, or a dotted path such as "trend.slope" for an analytics.TrendResult nested inside the evidence a DecisionContext carries) - this package does not invent a general-purpose expression/path language for that resolution; a Threshold's field is looked up the same straightforward way analytics.DataQualityScorer looks up required_columns against row mappings (spec 06 §25), not through a bespoke query DSL. An operational threshold is therefore always traceable to exactly one governed Policy version, one KPIResult.code or AnalyticsResult.model_code, and one comparator - three facts an auditor or a future AI agent (§36) can verify independently without re-deriving the decision logic that consumed them.
14. Decision Strategies¶
class DecisionStrategy(DecisionModel, ABC):
"""Category base for decision-making strategies -- the pluggable
'how do we decide' abstraction, analogous to analytics.TrendModel/
BenchmarkModel (spec 06 §14, §13) one layer down."""
class ThresholdDecisionStrategy(DecisionStrategy):
"""The default, concrete strategy: evaluate the active Policy's
Rules (via RuleEngine, §10) against the DecisionContext, and for
every triggered rule whose threshold is breached, produce one
Recommendation (§15)."""
def __init__(self, *, policy: Policy, rule_engine: "RuleEngine | None" = None) -> None: ...
def _decide(self, context: DecisionContext) -> "DecisionResult": ...
15. Recommendation Generation¶
@dataclass(frozen=True, slots=True)
class Recommendation(DecisionResult):
"""One recommended action, produced by a DecisionStrategy."""
policy_code: str
triggered_rules: "tuple[str, ...]"
summary: str
severity: "Literal['low', 'medium', 'high', 'critical']"
evidence: "tuple[str, ...]" # KPIResult.code / AnalyticsResult.model_code references
explanation: "Explanation | None" = field(default=None, kw_only=True)
ThresholdDecisionStrategy (§14) is the sole producer of Recommendation objects in this initial release - a Recommendation is always traceable to the specific Policy and Rule names that produced it (policy_code, triggered_rules), and to the specific KPIResult/AnalyticsResult evidence it was computed from (evidence), so the explanation stage (§17) never has to guess provenance after the fact.
16. Recommendation Ranking¶
class RankingStrategy(DecisionModel, ABC):
"""Category base for ranking strategies -- orders candidate
Recommendations by DecisionScore (§23). Distinct from
ActionPrioritizer (§20): ranking answers 'which recommendation is
best,' prioritization answers 'given limited capacity, which
action happens first' -- conflating the two is a recorded anti-
pattern (§33)."""
class WeightedScoreRanking(RankingStrategy):
"""The default, concrete ranking strategy: orders Recommendations
by DecisionScore.value, descending."""
def __init__(self, *, scorer: "DecisionScorer | None" = None) -> None: ...
@dataclass(frozen=True, slots=True)
class RankedRecommendation(DecisionResult):
recommendation: Recommendation
score: "DecisionScore"
rank: int
17. Recommendation Explanations¶
@dataclass(frozen=True, slots=True)
class Explanation(BaseValueObject):
"""A structured, evidence-linked justification -- never opaque
prose only. `premises` are human-readable statements; `evidence_refs`
are the exact KPIResult.code/AnalyticsResult.model_code values a
consuming system (including a future AI agent, §36) can re-fetch to
verify the claim independently, exactly the same "structured
contract over prose" rationale kpis §18 and analytics §37 already
established for their own describe()/result types."""
premises: "tuple[str, ...]"
evidence_refs: "tuple[str, ...]"
class ExplanationBuilder:
"""Walks a Recommendation's triggered_rules/evidence and produces
its Explanation. A concrete, non-pluggable utility -- not a
DecisionModel subclass, since explanation construction is not a
decision in itself."""
def build(self, recommendation: Recommendation, *, context: DecisionContext) -> Explanation: ...
class ExplanationStage(PipelineStage):
"""The PipelineStage wrapper composing ExplanationBuilder into a
DecisionPipeline as its terminal stage -- attaches an Explanation to
every Recommendation/RankedRecommendation already produced by the
upstream ModelStage(s), then wraps the fully-explained set into the
final DecisionResult the pipeline returns."""
def process(self, context: DecisionContext) -> DecisionContext: ...
18. Root-Cause Interfaces (interface only)¶
class RootCauseAnalyzer(DecisionModel, ABC):
"""The contract a future root-cause-analysis plugin implements.
THIS MODULE SHIPS NO CONCRETE SUBCLASS -- identifying *why* a
threshold was breached (which upstream signal, equipment, or
process step is the likely cause) is a causal-inference problem,
exactly the kind of modeling decision this package's charter
(§3.4) excludes. Defining the contract now lets a future,
independently-versioned plugin (or a future `agents`/`digital_twin`
capability, §36) register against a stable interface."""
@abstractmethod
def _analyze(self, symptom: "Recommendation | Alert", *, context: DecisionContext) -> "RootCauseResult": ...
@dataclass(frozen=True, slots=True)
class RootCauseResult(DecisionResult):
candidate_causes: "tuple[str, ...]"
confidence: "ConfidenceScore"
19. What-If Interfaces (interface only)¶
class WhatIfEngine(DecisionModel, ABC):
"""The contract a future what-if/scenario-simulation plugin
implements. THIS MODULE SHIPS NO CONCRETE SUBCLASS -- predicting
the outcome of a hypothetical change is a simulation/forecasting
problem (out of scope, §3.4, §4). This interface deliberately
reuses `events.AsOf`'s `scenario` field, which the Event Framework
spec already reserved 'so a future scenario/what-if-forking
capability (Digital Twin) can extend this type's usage without a
breaking change' (events spec 01) -- WhatIfEngine is exactly that
anticipated capability's decision-layer contract, not a new
scenario concept."""
@abstractmethod
def _simulate(
self, context: DecisionContext, hypothesis: "Mapping[str, Any]", *, as_of: "AsOf"
) -> "WhatIfResult": ...
@dataclass(frozen=True, slots=True)
class WhatIfResult(DecisionResult):
hypothesis: "Mapping[str, Any]"
predicted_outcome: str
confidence: "ConfidenceScore"
20. Action Prioritization¶
@dataclass(frozen=True, slots=True)
class ActionPriority(BaseValueObject):
urgency: float
impact: float
effort: float
@property
def priority_score(self) -> float:
return (self.urgency * self.impact) / max(self.effort, 1e-9)
class ActionPrioritizer:
"""Given a Sequence[RankedRecommendation], assigns each an
ActionPriority reflecting urgency/impact/effort under limited
execution capacity -- a distinct question from ranking (§16):
the single best recommendation is not always the one with the
most execution capacity available to act on it right now."""
def prioritize(self, ranked: "Sequence[RankedRecommendation]") -> "Sequence[tuple[RankedRecommendation, ActionPriority]]": ...
urgency, impact, and effort are supplied by the Policy/Recommendation that produced the candidate action (e.g. a Policy author declares a base urgency per severity level, §22; impact derives from the DecisionScore the recommendation already carries, §23; effort is a site-specific estimate a Policy or a caller supplies, since this package has no basis of its own for estimating how long an intervention takes). ActionPrioritizer combines these into one priority_score, but retains all three components on ActionPriority rather than collapsing them into the score alone - an operator reviewing a prioritized list can see why a low-impact-but-trivial-effort action outranked a high-impact-but-expensive one, the same "components carried alongside the aggregate" discipline DecisionScore (§23) already applies.
21. Action Planning¶
class ActionPlanner:
"""Sequences a set of prioritized actions, respecting declared
dependencies, into an ActionPlan. Implements its own narrow
topological ordering rather than reusing kpis.DependencyGraph,
which is tightly coupled to Registry[str, type[BaseKPI]] and
KPIMetadata.dependencies -- a shape that does not fit arbitrary
action-to-action dependencies (§3.3, §34)."""
def plan(
self, prioritized: "Sequence[tuple[RankedRecommendation, ActionPriority]]",
*, dependencies: "Mapping[str, tuple[str, ...]]" = MappingProxyType({}),
) -> Result["ActionPlan"]: ...
@dataclass(frozen=True, slots=True)
class ActionPlan(DecisionResult):
ordered_actions: "tuple[Recommendation, ...]"
priorities: "Mapping[str, ActionPriority]"
dependencies is an optional, caller-supplied mapping from an action's identifying key to the keys it depends on (e.g. "replace conveyor belt" before "resume full-rate crushing") - when empty (the default), ActionPlanner.plan degrades to simply ordering actions by priority_score, so a caller with no dependency information to supply pays no complexity cost. plan returns Result.err on a detected cycle rather than silently dropping or arbitrarily breaking it - the same "fail loudly on a cycle" posture kpis.DependencyGraph.detect_cycle already established for KPI dependencies (spec 05 §10.8), applied here to decision's own, structurally different dependency shape (§3.3).
22. Alert Generation¶
@dataclass(frozen=True, slots=True)
class Alert(DecisionResult):
message: str
severity: "Literal['low', 'medium', 'high', 'critical']"
scope: "Mapping[str, str]"
triggered_by: "ThresholdBreach | None" = field(default=None, kw_only=True)
class AlertGenerator:
"""Produces an Alert from a ThresholdBreach (§13) or a high-severity
Recommendation (§15). A concrete, non-pluggable utility -- alert
*channel* delivery (email, SMS, dashboard push) is explicitly out
of scope for this package (§4); AlertGenerator produces the Alert
value object only, never sends it anywhere."""
def from_breach(self, breach: "ThresholdBreach") -> Alert: ...
def from_recommendation(self, recommendation: Recommendation) -> "Alert | None": ...
23. Decision Scoring¶
@dataclass(frozen=True, slots=True)
class DecisionScore(BaseValueObject):
value: float
components: "Mapping[str, float]" # named contributing factors, for auditability
class DecisionScorer:
"""Computes the numeric weight (DecisionScore) backing ranking
(§16) -- a function of severity, policy weight, and (optionally)
ConfidenceScore (§24), never of raw KPI/Analytics values directly
re-interpreted."""
def score(self, recommendation: Recommendation, *, confidence: "ConfidenceScore | None" = None) -> DecisionScore: ...
24. Confidence Scoring¶
@dataclass(frozen=True, slots=True)
class ConfidenceScore(BaseValueObject):
value: float # 0.0-1.0
basis: "Literal['data_quality', 'rule_strength', 'combined']"
class ConfidenceScorer:
"""Computes ConfidenceScore, deriving from
analytics.DataQualityScore (spec 06 §25) plus how many/which rules
corroborated a Recommendation -- never recomputing data quality
itself (§3.2). If no DataQualityScore is available in the
DecisionContext's evidence, ConfidenceScorer falls back to
'rule_strength' basis only, and the resulting ConfidenceScore
records which basis was used so a consumer can distinguish 'we
checked data quality and it was good' from 'we had no data-quality
signal to check.'"""
def score(self, recommendation: Recommendation, *, context: DecisionContext) -> ConfidenceScore: ...
25. Real-Time Decision Evaluation¶
class RealTimeDecisionSession:
"""A long-lived session that subscribes to an events.EventBus and,
on each new relevant envelope, refreshes the necessary
kpis.KPIEngine/analytics.BatchAnalyticsRunner outputs and re-runs a
DecisionPipeline over the refreshed DecisionContext -- the live-
operations-center counterpart of BatchDecisionRunner (§26), mirroring
analytics.StreamingAnalyticsSession's role one layer down (spec 06
§27). RealTimeDecisionSession composes KPIEngine/BatchAnalyticsRunner
rather than recomputing anything itself (§3.2)."""
def __init__(
self, *, bus: "EventBus", pipeline: DecisionPipeline,
kpi_engine: "KPIEngine", analytics_runner: "BatchAnalyticsRunner",
) -> None: ...
def start(self) -> "Subscription": ...
def latest(self, scope: "Mapping[str, str]") -> "DecisionResult | None": ...
sequenceDiagram
participant Bus as events.EventBus
participant Sess as RealTimeDecisionSession
participant KPI as kpis.KPIEngine
participant AN as analytics.BatchAnalyticsRunner
participant Pipe as DecisionPipeline
Bus->>Sess: publish(envelope)
Sess->>KPI: execute(relevant KPI codes, refreshed scope)
KPI-->>Sess: KPIResult(s)
Sess->>AN: run(refreshed TimeSeries)
AN-->>Sess: AnalyticsResult(s)
Sess->>Pipe: run(refreshed DecisionContext)
Pipe-->>Sess: DecisionResult
Note over Sess: latest() serves the most recent result without re-running the pipeline
26. Batch Decision Evaluation¶
class BatchDecisionRunner:
"""Runs one DecisionPipeline once over a bounded, already-assembled
DecisionContext -- the 'normal,' scheduled-report mode, in contrast
to RealTimeDecisionSession's live, event-driven mode (§25). A thin,
named wrapper over DecisionPipeline.run (§9), exactly mirroring
analytics.BatchAnalyticsRunner's relationship to AnalyticsPipeline
(spec 06 §28)."""
def __init__(self, *, pipeline: DecisionPipeline) -> None: ...
def run(self, context: DecisionContext) -> Result[DecisionResult]: ...
27. Decision Audit Trail¶
@dataclass(frozen=True, slots=True)
class DecisionAuditEntry(BaseValueObject):
recorded_at: datetime
result: DecisionResult
context_scope: "Mapping[str, str]"
source_event_ids: "tuple[str, ...]" # events.EventID.value, where traceable
class DecisionAuditTrail:
"""Append-only record of every DecisionResult ever produced by any
DecisionPipeline run in this process -- the accountability
mechanism a business-decision system requires that a purely
statistical one (analytics) does not as urgently. Serializes via
core.serialization exactly as every other value object in this
platform does."""
def record(self, entry: DecisionAuditEntry) -> None: ...
def query(self, *, scope: "Mapping[str, str] | None" = None) -> "Sequence[DecisionAuditEntry]": ...
Thread safety. Like analytics.IncrementalAccumulator (spec 06 §29), DecisionAuditTrail is deliberately mutable (record() appends). It is expected to serialize concurrent record() calls internally (e.g. an append-only lock), so that RealTimeDecisionSession (§25) and BatchDecisionRunner (§26) can share one trail instance safely; query() is read-only and never blocks on a concurrent record() beyond whatever the chosen synchronization strategy requires.
What "traceable" means in practice. DecisionAuditEntry.source_event_ids is populated on a best-effort basis: it is straightforward to populate when a DecisionContext's event_store field (§8) was used to construct the KPIResult/AnalyticsResult evidence in the same call chain (the relevant EventIDs are already known to whichever EventQuery produced those results), but is legitimately empty when a DecisionContext was assembled from evidence gathered elsewhere (e.g. a KPIResult a caller computed hours earlier and passed in directly). An empty source_event_ids tuple is not an error - DecisionAuditEntry.result, context_scope, and recorded_at alone already satisfy this package's accountability objective (§2.2) by recording what was decided, for what scope, when, and (via Recommendation.evidence/Explanation.evidence_refs, §15, §17) from which KPI/Analytics facts - event-level traceability is an additional, not a required, layer of provenance.
28. Result Models¶
@dataclass(frozen=True, slots=True)
class DecisionResult(BaseValueObject):
"""The shared envelope every concrete decision output composes --
mirrors analytics.AnalyticsResult's role (spec 06 §30), one layer
up."""
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 - Recommendation (§15), RankedRecommendation (§16), ActionPlan (§21), Alert (§22), RootCauseResult (§18), WhatIfResult (§19) - subclasses DecisionResult. Explanation (§17), DecisionScore (§23), ConfidenceScore (§24), ActionPriority (§20), Threshold (§13), and ThresholdBreach are deliberately plain BaseValueObjects, not DecisionResult subclasses, since each represents a supporting value attached to a decision output rather than a decision output itself - the same distinction spec 06 §30 already drew between AnalyticsResult and AnomalyFlag/OutlierFlag.
@dataclass(frozen=True, slots=True)
class ThresholdBreach(BaseValueObject):
threshold: Threshold
observed_value: float
breached_at: datetime
Serialization. Every DecisionResult subclass, like every AnalyticsResult/KPIResult subclass before it, serializes via core.serialization (DataclassSerializer/to_dict) with no bespoke per-type serializer, which is what lets DecisionAuditTrail (§27) persist entries uniformly and what lets a future digital_twin/agents consumer (§36) treat any decision output as plain, schema-stable structured data.
29. Decision Lifecycle and Versioning¶
Two independent lifecycles apply, mirroring the split spec 05 §11 already drew between a KPI's type lifecycle and a KPIResult instance's lifecycle:
Policylifecycle (DecisionStatus, §12):Proposed → Active → Superseded → Retired. AnActivepolicy is a public contract; changing its rules/thresholds without a version bump and aSupersededtransition for the prior version raisesPolicyConflictErrorat registration time, never silently.DecisionResultinstance lifecycle: computed on demand by aDecisionPipelinerun, recorded toDecisionAuditTrail(§27), never mutated once produced (it is aBaseValueObject).
DecisionMetadata.version (§30) governs a registered DecisionModel implementation's own SemVer, independently of any Policy's version - a DecisionStrategy implementation and the Policy data it evaluates are versioned separately, exactly as kpis.ExecutionBackend implementations version independently of KPIMetadata.version (spec 05 §10.9, §20).
30. Decision Metadata¶
class DecisionCategory(Enum):
"""Closed enum -- adding a member is a governance-reviewed change,
mirroring analytics.AnalyticsCategory's closed-enum rule (spec 06
§31)."""
STRATEGY = "strategy"
RANKING = "ranking"
ROOT_CAUSE = "root_cause"
WHAT_IF = "what_if"
@dataclass(frozen=True, slots=True)
class DecisionMetadata(BaseMetadata):
"""The minimal registration schema for a discoverable DecisionModel
-- as light as analytics.AnalyticsMetadata (spec 06 §31), not as
heavy as kpis.KPIMetadata, because a DecisionModel implementation is
a computational strategy, not itself the governed business artifact
(that weight belongs to Policy, §12, §3.6)."""
code: str
category: DecisionCategory = field(kw_only=True)
description: str = field(kw_only=True)
version: str = field(default="1.0.0", kw_only=True)
def validate(self) -> None:
if not self.code.strip():
raise ValidationError("DecisionMetadata.code must not be empty")
31. Extension Points¶
- New concrete strategy within an existing category. Subclass
DecisionStrategyorRankingStrategy, completeDecisionMetadata, implement_decide, decorate with@register(§32). No existing model class is ever edited to add a new one. - A concrete root-cause or what-if implementation. Subclass
RootCauseAnalyzer/WhatIfEngine(§18, §19) - the first such subclass to exist, whether first-party or 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 aDecisionPipeline(§9) without any change toDecisionPipelineitself. - A new
Policy. Authored and registered like any other governed artifact (§12) - no code change required to add a new business policy. - A new
DecisionCategory. A closed-enum change requiring governance (§30), mirroringanalytics.AnalyticsCategory's precedent (spec 06 §31) andkpis.Aggregation's (spec 05 §16.4).
32. Plugin Integration¶
Identical mechanism to every other extension point in the platform, specialized for Decision Intelligence exactly as analytics._registry/kpis._registry specialize it (spec 06 §33, spec 05 AD-KP-06):
# decision/_registry.py
from mineproductivity.registry import Registry
REGISTRY: "Registry[str, type[DecisionModel]]" = Registry(name="decision")
def register(cls: "type[DecisionModel]") -> "type[DecisionModel]":
"""Register cls into REGISTRY, keyed by cls.meta.code -- same shape
as analytics.register/kpis.register, raising DecisionValidationError
for an empty code and DecisionVersionConflictError for a duplicate,
non-identical re-registration."""
Discovery uses registry.EntryPointDiscovery/registry.EntryPointSpec (spec 03) exactly as kpis, connectors, and analytics already do - EntryPointSpec(group="mineproductivity.decision", target_registry="decision") - with the identical per-entry-point isolation guarantee (spec 03 §11).
33. Anti-Patterns¶
- ❌ Recomputing or reinterpreting a KPI value or an Analytics result inside
decisioninstead of readingkpis.KPIResult/analytics.AnalyticsResultdirectly. If a fact is KPI- or Analytics-shaped, it comes from that layer, full stop (§3.2). - ❌ Writing ad hoc
if/elsebusiness logic scattered acrossDecisionStrategysubclasses instead of composingRules viacore.BaseSpecification(§10, §11) - rule logic belongs in composable, inspectableRulevalues, not in imperative branches buried in a strategy's_decide. - ❌ Shipping a concrete
RootCauseAnalyzer/WhatIfEngineimplementation in this package. Both are interfaces only, by explicit design (§18, §19); adding a concrete subclass here is a scope violation of the causal-inference/simulation boundary, not a convenience. - ❌ Forcing
ActionPlannerto reusekpis.DependencyGraphby encoding actions as fake KPI codes merely to reuse its topological-sort code.kpis.DependencyGraphis coupled toRegistry[str, type[BaseKPI]]/KPIMetadata.dependencies;ActionPlannerimplements its own narrow ordering instead (§3.3, §21). - ❌ Conflating ranking (§16) with prioritization (§20). "The statistically best recommendation" and "what should be done first given limited capacity" are different questions with different inputs; a single combined score collapsing both loses the ability to reason about either independently.
- ❌ Editing an
ActivePolicyin place instead of publishing a new version and moving the prior one toSuperseded(§12, §29) - identical rule to never silently repointing a KPI code (spec 05 §17). - ❌ Importing
mineproductivity.connectorsfor anything beyond the permitted-but-unexercised layering rule (§5) -decisionoperates on already-computedKPIResult/AnalyticsResultobjects, never on a vendor-specific wire format. - ❌ A
_decideimplementation raising an exception for a legitimately no-recommendation input (no policy applies) instead of returning aDecisionResultwith zero recommendations (§8) - identical rule tokpis.BaseKPI.compute()/analytics.AnalyticsModel.analyze(). - ❌ Bypassing
DecisionAuditTrailfor anyDecisionPipelinerun whose output could plausibly inform a real operational action - an unaudited recommendation is, per this package's accountability objective (§2.2), not meaningfully different from an unregistered KPI code (spec 05 §32: "if it is worth reporting, it is worth registering," reapplied here as "if it is worth acting on, it is worth auditing"). - ❌ Producing a
Recommendationwith noevidenceand noExplanation. EveryRecommendationtraces to at least oneKPIResult.code/AnalyticsResult.model_codeand, by the time aDecisionPipelinefinishes, has a populatedExplanation(§15, §17) - a recommendation aDecisionStrategycannot justify with a specific piece of evidence is a sign the triggeringRulewas too loosely written, not a reason to ship the recommendation unexplained. - ❌ Treating
ActionPriority'spriority_scoreas the only number that matters and discardingurgency/impact/effortonce computed (§20) - the components are part of the public contract precisely so a human reviewing a prioritized list can audit the arithmetic, not merely trust it. - ❌ Sending an
Alertto an external channel from insideAlertGenerator.AlertGeneratorproduces theAlertvalue object only (§22); channel delivery (email, SMS, a dashboard push) is a caller's or a future package's responsibility, kept out of this package precisely soAlertGeneratorremains a pure, easily-tested function with no I/O side effects.
34. Testing Philosophy¶
Decision Intelligence's testing strategy follows the same layered structure kpis and analytics established (spec 05 §29, spec 06 §35), adapted to this package's shape:
- Unit tests per concrete strategy -
ThresholdDecisionStrategy,WeightedScoreRankingeach tested against hand-authoredPolicy/Thresholdfixtures with known expectedRecommendation/RankedRecommendationoutputs. - Rule composition tests -
Rulevalues composed via&/|/~tested for correct boolean semantics over syntheticDecisionContexts, reusingcore.specification's own test patterns rather than inventing new ones. - Policy governance tests -
PolicyConflictErrorraised for a re-registration attempt that changes anActivepolicy's rules/thresholds without a version bump and aSupersededtransition. - Confidence-scoring correctness tests -
ConfidenceScorer.scoretested against aDecisionContextwith and without aDataQualityScorepresent in its evidence, asserting the correctbasis("data_quality" vs. "rule_strength") is recorded in each case. - Registry/discovery isolation tests - mirror
tests/integration/test_registry_plugin_discovery.py's healthy/broken fixture-plugin pattern, specialized forDecisionModel. - Interface-only module tests -
RootCauseAnalyzer/WhatIfEnginetested only for their ABC contract (bare-ABC instantiation raisesTypeError); no algorithmic-correctness test exists for either. - Action-planning ordering tests -
ActionPlanner.plantested against a synthetic action-dependency graph, proving correct topological ordering and correct rejection of a cyclic dependency set. - Real-time/batch parity tests -
RealTimeDecisionSession.latest()and aBatchDecisionRunnerrun over the same assembled context produce the sameDecisionResult, within the same toleranceanalytics' own streaming/batch parity tests already establish (spec 06 §35).
Package acceptance proofs, mirroring spec 06 §35's shape one layer up:
- No fact-recomputation proof: a static analysis of every module in
src/mineproductivity/decision/contains zero direct reads of raw event rows or independent KPI/statistical computation - every KPI-shaped or Analytics-shaped value entering this package's tests arrives viakpis.KPIEngine/analytics.BatchAnalyticsRunneror a fixture standing in for them. - Rule-isolation proof: a single malformed
Rule(one whose predicate raises) never preventsRuleEngine.evaluatefrom correctly evaluating the remaining rules in the samePolicy. - Interface-purity proof:
RootCauseAnalyzerandWhatIfEngineeach have zero concrete, non-test subclasses anywhere insrc/mineproductivity/decision/. - No architectural drift:
decisionappears in the platform's dependency graph exactly per §5; the forbidden-imports check (no lower package importsdecision;decisionimports nothing above itself) passes mechanically. - Audit completeness proof: every
DecisionResultproduced by aDecisionPipelinerun in a certification fixture has a correspondingDecisionAuditEntry.
35. Performance Considerations¶
- No independent large-scale computation -
decisionnever scans raw events or recomputes statistics; its performance profile is dominated by rule evaluation over already-smallDecisionContextobjects (a handful ofKPIResult/AnalyticsResultvalues per decision), not by the volumekpis/analyticsthemselves must handle. RealTimeDecisionSessionreuses, never duplicates,kpis/analytics' own performance postures (§25) - it delegates toKPIEngine.execute()(column-pruned, cached per spec 05 §22) andBatchAnalyticsRunner(delegating toExecutionBackend/IncrementalAccumulatorper spec 06 §36) rather than re-fetching or re-aggregating raw data itself.DecisionAuditTrailappend cost is O(1) per entry;query()is expected to support scope-based filtering without a full linear scan at production audit-trail sizes, the same "do not assume small-N forever" posture spec 06 §36 already establishes for its own accumulators.ActionPlanner's topological sort is O(V+E) over the declared action-dependency graph, the standard complexity bound for topological ordering, with no hidden re-computation across repeatedplan()calls for the same input.RuleEngine.evaluateis O(R) in the number of rules in the activePolicy, each a singleis_satisfied_bycall over one already-assembledDecisionContext- there is no scan over historical decisions or historical events in the rule-evaluation path itself; history, where relevant, arrives pre-summarized as anAnalyticsResult(aBaselineorTrendResult, spec 06 §14-§15) rather than being re-scanned by aRule.Policylookup is registry-speed, not file-speed - anActivePolicyis held in memory (viadecision.REGISTRYor an equivalent policy store keyed the same way), so evaluating aDecisionContextagainst it never involves a disk or network round-trip on the hot path; publishing a newPolicyversion (§12, §29) is an infrequent, out-of-band operation, not somethingRuleEngineStage.processpays for on every pipeline run.
36. 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 + decision (this spec)"]
CORE[core] --> ONT[ontology] --> EVT[events] --> KPI[kpis] --> AN[analytics] --> DEC[decision]
end
subgraph future["Future, undesigned -- extension points only"]
TWIN[digital_twin]
SIM[simulation]
OPT[optimization]
VIZ[visualization]
AGENTS[agents]
end
DEC -.WhatIfEngine ABC + events.AsOf.scenario hook, §19.-> TWIN
DEC -.ActionPlan / RealTimeDecisionSession, §21 §25.-> SIM
DEC -.ActionPriority / DecisionScore, §20 §23.-> OPT
DEC -.stable DecisionResult family, §28.-> VIZ
DEC -.Explanation / RootCauseAnalyzer ABC, §17 §18.-> AGENTS
digital_twinis the most direct consumer of theWhatIfEngineinterface (§19): a live simulated-state twin is exactly the kind of capability positioned to implement_simulateagainstevents.AsOf's already-reservedscenariofield - the extension point this package exposes is that the interface and theAsOfhook both already exist, waiting for that implementation.simulationwill likely consumeActionPlan(§21) as a candidate sequence of interventions to simulate forward, and may driveRealTimeDecisionSession(§25) with simulated rather than real events - the extension point is that neither type carries anydecision-internal state that would prevent this.optimizationwill likely consumeActionPriority/DecisionScore(§20, §23) as objective-function inputs when searching for an optimal action sequence across many competingRecommendations - the extension point is that both are plain, serializable value objects.visualizationwill likely render theDecisionResultfamily (§28) directly - the extension point is that every result type already serializes viacore.serializationwith no bespoke per-type contract to learn.- AI Agents (a future
agentspackage) will likely consumeExplanation(§17) as the structured, evidence-linked justification an agent needs to act autonomously or to explain its own action to a human, and may eventually be the first implementer ofRootCauseAnalyzer(§18) - the extension point is that both already return structured, machine-readable objects rather than prose, exactly the rationalekpis§18 andanalytics§37 already established one and two layers down.
None of the five items above constitutes a design for digital_twin, simulation, optimization, visualization, or agents - each is restricted to naming which of this package's already-specified public types that future package is expected to consume.
Notably, optimization and simulation are positioned to consume this package's outputs (ActionPlan, ActionPriority) rather than the reverse - this specification does not anticipate decision ever needing to call into a future optimization/simulation package, since doing so would violate the same "decision reasons over already-produced facts, never generates them" principle (§3.1) this package already applies to analytics/kpis. If a future need arises for decision to consume an optimizer's or a simulator's output as an input to a recommendation (e.g. "the optimizer found a better shift schedule; recommend it"), that input would arrive the same way kpis.KPIResult/analytics.AnalyticsResult already do today - as a well-formed value object DecisionContext carries - and would not require decision to import a package above it in the stack.
End of Decision Intelligence Design Specification. See docs/design/07_Decision_Intelligence_Implementation_Checklist.md for the actionable implementation contract, and docs/adr/ADR-0007-Decision-Intelligence.md for the architecture decision record governing this package's existence as a separate layer.