This document specifies how the components scoped in Strategy Plan §2 are built — interface contracts, data model, technology choices, and the concrete mechanisms behind each non-negotiable principle from the Charter. It does not revisit what ships or why — those are settled. Every interface below is formalized against a specific reference implementation named in Strategy Plan §7, not designed speculatively. Phase 4 (Development & Implementation) builds directly against this document.
typing.Protocol definitions, not pseudocode. They are intentionally complete enough to implement directly against, and intentionally silent on internals: EM specifies contracts, not implementations. Each section cites the reference implementation it formalizes.
Python 3.11+, structural typing via typing.Protocol. Every reference implementation in Strategy Plan §7 is Python — haiva, nara, LWE, and TRAXON all are. Formalizing EM in the same language means the reference implementations can be directly consulted, and eventually directly tested against the new interfaces, during Phase 4. Protocol (PEP 544) is chosen over abstract base classes deliberately: it gives EM a real plugin model (§11) — a new Source implementation satisfies the contract by structural conformance, without inheriting from an EM base class or importing EM at all if it doesn't need to. This matters directly for the open-source access model (Strategy §3): a contributor should be able to write a conformant Source without EM's package being a hard dependency of their own code.
See ADR-0001 for the full reasoning and alternatives considered.
Shared types every component operates on. All are frozen dataclasses — immutable by construction, consistent with the Charter's statelessness principle (§10).
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Literal
from uuid import uuid4
class SourceStatus(Enum):
"""Per Strategy §2 / Source disposition — fallback must be distinguishable
from a live reading, not numerically identical (TRAXON emtech:latest finding)."""
LIVE = "live"
FALLBACK = "fallback"
STALE = "stale"
@dataclass(frozen=True)
class SourceReading:
source_name: str
value: float # normalized [0,1]
weight: float # base declared weight
status: SourceStatus
observed_at: datetime
@dataclass(frozen=True)
class SourceContribution:
"""One Source's contribution to a fused state — the unit Explainability is built on."""
source_name: str
value: float
effective_weight: float # base_weight × calibration multiplier, if any
contribution_share: float # effective_weight / sum(available effective_weights)
@dataclass(frozen=True)
class FusedState:
# NOTE: id added post-M4 (ADR-0007) — the field was missing from the
# original spec, forcing Gate and ProvenanceStore to each derive an
# ad-hoc content hash independently. This is the real, assigned
# identity every consumer should reference instead.
id: str = field(default_factory=lambda: uuid4().hex)
value: float = field(kw_only=True)
contributions: tuple[SourceContribution, ...] = field(kw_only=True)
incongruence_detected: bool = field(kw_only=True)
incongruence_detail: str | None = field(kw_only=True)
computed_at: datetime = field(kw_only=True)
@dataclass(frozen=True)
class ProvenanceRecord:
"""Attribution half only — v1 scope, see §7. No correctness field: that is v2."""
source_name: str
value_at_time: float
fused_state_ref: str
recorded_at: datetime
v1 Formalized against LWE's chrome-extension\ job scraper (Strategy §7) — the audit's strongest three-criteria satisfaction.
from typing import Protocol, runtime_checkable
@runtime_checkable
class Source(Protocol):
def fetch(self) -> object:
"""Retrieve the raw signal. Implementation-defined return; opaque to EM."""
...
def score(self, raw: object) -> float:
"""Normalize raw to [0,1]. Must raise, never silently clamp out-of-range."""
...
def base_weight(self) -> float:
"""Declared base weight, before any Calibrator adjustment."""
...
def fallback(self) -> SourceReading:
"""Called when fetch()/score() raise or the Source is otherwise unavailable.
Must return status=SourceStatus.FALLBACK — never LIVE."""
...
Three structural shapes satisfy this Protocol without any change to it (Candidate Matrix §2.1): a single class implementing all four methods (co-located — haiva's PBM wearables, LWE's scraper); a composition of a separate fetcher and scorer object exposing the same four methods between them (TRAXON's src\data\ + conviction_score.py split); or an agentic implementation where fetch/score delegate to an LLM-orchestrated skill (LWE's gmail_sync\) — see §12 for why this third shape is a documented extension point, not a v1 deliverable.
v1 Numeric-weighted mode only. Formalized against haiva's signal_fusion.py and TRAXON's conviction_score.py — independently convergent on the identical formula (Candidate Matrix §2.2, the strongest single piece of evidence in Phase 1).
@runtime_checkable
class Fuser(Protocol):
def fuse(self, readings: tuple[SourceReading, ...]) -> FusedState:
"""Weighted average renormalized over available (non-FALLBACK) sources.
Implementations MAY still include FALLBACK readings at reduced trust —
that policy is the implementation's choice, not this contract's.
Must populate incongruence_detected when contributing values diverge
beyond the implementation's own declared threshold (haiva's façade
detection; TRAXON's 2×2 independence test — both satisfy this via
different concrete tests, which is why the test itself is not
specified here)."""
...
Fuser. That is Router composed with Gate (§9, §8). This is the single most load-bearing distinction in this document; conflating the two was the most common misclassification across all four audited repos.
v1 Both proven strategies supported as interchangeable implementations. Formalized against haiva's calibrate_formulas.py (batch, threshold-gated) and TRAXON's calibration.py (continuous shrinkage).
The anti-pattern confirmed in 3 of 4 systems — and, notably, inside both systems that also got Calibrator right — is confidence rising because a claim was seen again, not because a prediction was checked against what actually happened. Strategy §2 requires this be a structural guardrail, not a naming convention. It is enforced here at the type level:
@dataclass(frozen=True)
class OutcomeEvidence:
"""Cannot be constructed from corroboration, repetition, or manual
confirmation alone. Every field below must trace to something that
actually happened, not to a claim being repeated or clicked-confirm.
This is the type-level form of the Candidate Matrix §4 litmus test:
'did the same claim get observed again, or was a stored prediction
checked against something that occurred?' — only the latter
constructs this type."""
source_name: str
predicted_direction: Literal["positive", "negative"]
realized_outcome: Literal["positive", "negative"] # NOT a repeat-sighting count
outcome_observed_at: datetime
# deliberately no "corroboration_count" or "confirmed_by_user" field —
# if a strategy needs one, it does not construct OutcomeEvidence,
# it is not a Calibrator per this contract (see anti-pattern note above)
@dataclass(frozen=True)
class CalibrationUpdate:
source_name: str
previous_weight: float
new_weight: float
sample_size: int
strategy: Literal["batch_threshold_gated", "continuous_shrinkage"]
@runtime_checkable
class Calibrator(Protocol):
def record_outcome(self, evidence: OutcomeEvidence) -> None:
"""The only way evidence enters a Calibrator. There is no method that
accepts a corroboration count or a confirmation flag — if your
implementation needs one, you are not implementing this Protocol."""
...
def recalibrate(self, source_name: str, min_sample_size: int) -> CalibrationUpdate | None:
"""source_name is required, not optional — see ADR-0008. A Calibrator
tracks evidence for many sources at once (record_outcome's
OutcomeEvidence carries its own source_name); recalibrate() does
not implicitly pick one to prioritize, since evidence accumulates
without ever being consumed or reset, and any implicit-selection
policy risks silently starving whichever source isn't chosen.
Callers recalibrating multiple sources do so with one explicit
call per source. Returns None below min_sample_size — both
reference implementations enforce a floor (TRAXON: n≥5; haiva:
n≥50) rather than adjusting weight on thin evidence."""
...
def effective_weight(self, source_name: str) -> float:
"""Read interface for live scoring — a multiplier near 1.0, not an
absolute weight (see SourceContribution, §3: effective_weight =
base_weight × this multiplier). Must degrade safely to neutral
(1.0) if no calibration has occurred yet — NOT to the Source's
base_weight directly, which is a separate, already-known value
the caller multiplies this against."""
...
See ADR-0002.
v1 Attribution half only. v2 Correctness half — deferred per Strategy §2, the weakest-evidenced component in Phase 1 (1 of 4 systems).
@runtime_checkable
class ProvenanceStore(Protocol):
def record(self, contributions: tuple[SourceContribution, ...], fused_state_ref: str) -> None:
"""Attribution only: which Source, what value, when, contributed to
which fused state. No correctness field exists on ProvenanceRecord
in v1 — do not add one speculatively; see ADR-0004."""
...
def lookup(self, fused_state_ref: str) -> tuple[ProvenanceRecord, ...]:
...
Storage-agnostic by requirement (Charter §5) — this Protocol says nothing about Postgres, Neo4j, or any backend. haiva and TRAXON both implement equivalent attribution in Neo4j edges; LWE implements it as a Postgres/Neo4j hybrid. All three are valid concrete implementations of the same abstract contract.
v1 Formalized against TRAXON's ks36_monitor.py (structural inform-vs-act guarantee) and haiva's compute_crisis_level (threshold/override/hysteresis shape).
Gate.evaluate() returns a GateDecision that has no method, field, or code path leading to action. Crossing to action requires separately constructing an ActionAuthorization — which cannot be built from a GateDecision alone.
class Classification(Enum):
NONE = "none"
YELLOW = "yellow"
ORANGE = "orange"
RED = "red"
CRITICAL = "critical"
@dataclass(frozen=True)
class GateDecision:
"""Deliberately has no `.execute()`, no `.act()`, no boolean the caller
could mistake for authorization. This class cannot cause anything to
happen — by omission, not by convention."""
classification: Classification
confidence: float
fused_state_ref: str
decided_at: datetime
@dataclass(frozen=True)
class ActionAuthorization:
"""The ONLY path from a GateDecision to anything actionable. Must be
constructed explicitly, outside Gate/EM's own code — EM never
constructs this type itself. Every instance is logged with its own
audit trail and an expiry; there is no unauthorized/indefinite form."""
gate_decision_ref: str
authorized_by: str # human identity or explicit external system, never "system"
scope: str # what this authorization actually permits — never blanket
authorized_at: datetime
expires_at: datetime # mandatory — no non-expiring authorization
@runtime_checkable
class Gate(Protocol):
def evaluate(self, state: FusedState) -> GateDecision:
"""State + confidence out. Nothing else. See ActionAuthorization above
for the only sanctioned path onward — it does not live on this
Protocol, deliberately."""
...
See ADR-0003.
v1 Shapes 1 and 3 only (Strategy §2). Shapes 2/4/5/6 are documented below as candidate extensions, not part of this contract.
class RoutingTier(Enum):
LOW = "low" # cheapest path
ELEVATED = "elevated"
HIGH = "high" # most expensive/rigorous path
@dataclass(frozen=True)
class RouterDecision:
tier: RoutingTier
trigger_shape: Literal["stakes_proportional", "cost_gated_second_opinion"]
rationale: str
@runtime_checkable
class Router(Protocol):
def route(self, state: FusedState | GateDecision) -> RouterDecision:
"""Shape 1 (stakes-proportional, nara's get_nara_llm_tier / LWE's
check_experience_duplicates): tier scales with computed stakes or
uncertainty. Shape 3 (cost-gated second opinion, haiva's
_run_l2_safety_check): a cheap check runs first; an expensive one
fires only if the cheap one is inconclusive or flags escalation.
Both are valid implementations of this one Protocol — the shape is
an implementation detail, not a separate interface."""
...
| Charter principle | Concrete mechanism in this document |
|---|---|
| Explainability | FusedState.contributions is mandatory, not optional — every fused value carries the full breakdown of which Sources contributed what, at what weight (§3) |
| Auditability | CalibrationUpdate and ActionAuthorization are both immutable, timestamped records — a Calibrator adjustment or an action authorization can always be reconstructed from what was stored, not just current state |
| Storage-agnosticism | Every Protocol in §4–9 is silent on persistence technology; ProvenanceStore (§7) is the only component that touches storage at all, and it does so through an abstract interface, not a schema |
| Statelessness of core logic | All data model types (§3) are frozen — Fuser.fuse() and Gate.evaluate() are pure functions of their inputs by construction, not by convention |
| No placeholders / mock data | Enforced at Phase 4 via the Definition of Done (Strategy §8 / Development Plan, next); not an architectural mechanism but flagged here as a standing constraint on every implementation of every Protocol above |
Because every contract in §4–9 is a Protocol, a new implementation satisfies EM's interface by structural conformance — no registration step, no EM base class, no import of EM required unless the implementer wants runtime_checkable validation. A new Source (a new wearable, a new API) is just a class with fetch/score/base_weight/fallback. A new Gate policy is a class with evaluate. This is what makes TRAXON's split Fetcher+Scorer shape (§4) and a future agentic Source (§12) satisfy the same contract without EM's core changing at all.
Three implementation modes were found in the audit (Candidate Matrix §3a): deterministic, single-LLM-call-mediated, and LLM-orchestrated-agentic. Every Protocol above is mode-agnostic by design — nothing in a method signature requires or forbids an LLM call inside it. What v1 actually ships as reference implementations is narrower than what the contracts permit:
Source Protocol exactly as a pure-arithmetic one does.Source Protocol's method signatures do not preclude an agentic implementation later.Gate.evaluate()'s signature accommodates a probabilistic implementation without modification; v1 ships only the deterministic-threshold reference.OutcomeEvidence cannot be constructed from a corroboration-count or confirmation-flag alone (this is the guardrail's actual regression test — if this test can be made to pass with a corroboration-shaped input, the guardrail has failed).GateDecision to ActionAuthorization without an explicit external call.fallback() returns SourceStatus.FALLBACK, never LIVE, and that a live reading numerically equal to the fallback value is still distinguishable by status.Full records live in the repository at docs/adr/, one file per decision, plain markdown per Phase 4 convention.
Echo Module Architecture & Design v1.0 · Draft · Companion to Charter v1.1, Strategy Plan v1.0, and EM_Candidate_Matrix.md