Echo Module (EM)

Architecture & Design · Version 1.0 · Draft · Owner: Jean-Baptiste Decorzent
STATUS: DRAFT — PENDING APPROVAL
Table of contents

1 · Purpose & Scope

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.

Reading this document
Code blocks are real Python — 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.

2 · Technology & Typing Approach

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.

3 · Core Data Model

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

4 · Source

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.

5 · Fuser

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)."""
        ...
Resolved rule, carried from the audit directly into this contract
A mechanism that selects the single best available Source and falls back to the next on failure — without blending ≥2 available sources into one value — does not satisfy 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.

6 · Calibrator — the Guardrail

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.

7 · ProvenanceStore — v1 Scope

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.

8 · Gate — the Friction Requirement

v1 Formalized against TRAXON's ks36_monitor.py (structural inform-vs-act guarantee) and haiva's compute_crisis_level (threshold/override/hysteresis shape).

The composition-boundary requirement, made concrete
TRAXON's own audit found every individual Gate-shaped component conformant — and the wired system still autonomously acted, because nothing stood between a Gate's output and a live trading loop except one boolean flag. Strategy §2 requires this be structurally impossible in EM, not just discouraged. The mechanism: 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.

9 · Router

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."""
        ...

Documented, not built — Shapes 2/4/5/6 (single-system evidence, Candidate Matrix §2.6)

10 · Non-Functional Requirements → Mechanisms

Charter principleConcrete mechanism in this document
ExplainabilityFusedState.contributions is mandatory, not optional — every fused value carries the full breakdown of which Sources contributed what, at what weight (§3)
AuditabilityCalibrationUpdate 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-agnosticismEvery 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 logicAll 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 dataEnforced 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

11 · Extension Model

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.

12 · Implementation-Mode Scope for v1

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:

13 · Testing Strategy

14 · ADR Index

Full records live in the repository at docs/adr/, one file per decision, plain markdown per Phase 4 convention.

ADR-0001 — Python + typing.Protocol over an ABC-based or non-Python approach ADR-0002 — Calibrator requires an explicit OutcomeEvidence type, not a bool ADR-0003 — Gate requires an explicit ActionAuthorization to cross inform→act ADR-0004 — ProvenanceStore ships attribution-only in v1 ADR-0005 — Agentic and probabilistic-model sub-modes are extension points, not v1 deliverables

Echo Module Architecture & Design v1.0 · Draft · Companion to Charter v1.1, Strategy Plan v1.0, and EM_Candidate_Matrix.md