Skip to content

feat(format): deterministic JSON, Markdown, webhook, and WebSocket formatters#5

Merged
Mathews-Tom merged 11 commits into
mainfrom
feat/deterministic-formatters
Apr 17, 2026
Merged

feat(format): deterministic JSON, Markdown, webhook, and WebSocket formatters#5
Mathews-Tom merged 11 commits into
mainfrom
feat/deterministic-formatters

Conversation

@Mathews-Tom

@Mathews-Tom Mathews-Tom commented Apr 17, 2026

Copy link
Copy Markdown
Owner

Summary

Delivers the deterministic output formatters that consume SignalContext and deliver to every subscribed consumer. Canonical JSON with byte-identical stability, per-signal-type Jinja2 Markdown templates, pure severity derivation, webhook adapter with Slack Block Kit, WebSocket broadcaster with structured frames, closed-enum validation, and consumer routing. The IntelligenceBrief schema contract is published here for the gated secondary formatter that lands in the next phase.

What Changed

  • Canonical JSON: to_canonical_json with stable key ordering, six-decimal float rounding, Z-suffix UTC timestamps.
  • Severity: derive_severity is a pure function of magnitude * confidence * tier returning {high, medium, low}.
  • Markdown: Jinja2 renderer with one template per signal type extending _base.md.j2; templates ship inside the wheel.
  • Validators: ConsumerEnumValidator rejects briefs with unknown actionable_for values; load_schema reads exported schemas.
  • Webhook: WebhookFormatter with JSON, Markdown, and Slack Block Kit formats; retry on 5xx/429, drop on 4xx; env-sourced auth headers.
  • WebSocket: WebSocketBroadcaster with SIGNAL/HEARTBEAT/STORM_START/STORM_END frames; oldest-drop under pressure.
  • Routing: ConsumerRegistry.from_toml loads config/consumers.toml; SignalRouter maps contexts to consumer sets with suppression for llm_assisted mode.
  • IntelligenceBrief: contract + schemas/IntelligenceBrief-1.0.0.json exported.
  • Config: config/formatters.toml with JSON, Markdown, webhook, and WebSocket blocks.

How It Works

Everything upstream of the formatters is deterministic; these formatters preserve that invariant so consumers can hash outputs and rely on stable equality. Canonical JSON and Markdown both render byte-identically across invocations. Severity derivation lives in code so any consumer can reproduce the tier-threshold mapping locally without a network round trip. The webhook adapter and WebSocket broadcaster are deterministic wire formats over a deterministic payload; no stochastic behavior in the entire formatter surface.

Configuration Added

  • config/formatters.toml — JSON decimals, Markdown template path, webhook retry policy, WebSocket bind and heartbeat settings.

Schema Changes

  • New: schemas/IntelligenceBrief-1.0.0.json. No changes to the four Phase-1 schemas.

Quality Gates

  • uv run ruff check . clean
  • uv run ruff format --check . clean
  • uv run mypy --strict src/ clean (106 source files)
  • uv run pytest --cov-fail-under=80 passes; 231 tests, 88.0 % coverage
  • LLM-import guard passes (formatter package has zero LLM imports)
  • Schema export in sync (uv run python scripts/export_schemas.py --check)
  • Forbidden-token doc lint passes
  • datetime.now() AST guard passes
  • uv run pre-commit run --all-files passes
  • CI workflow runs green on this PR (pending remote run)

Definition of Done

  • All files exist; strict lint, format, and type checks clean.
  • 1000-call determinism test on canonical JSON passes.
  • Every signal type's Markdown template renders; required fields present; manipulation block conditional on non-empty flags.
  • Severity derivation matches the documented formula on a curated test set covering every boundary.
  • Webhook adapter retries on 5xx, drops on 4xx, exhausts after configured attempts.
  • Slack Block Kit output shape validated (header/section/context blocks).
  • WebSocket broadcaster fans out, filters by consumer_type, drops oldest on full queue.
  • Closed-enum validator rejects briefs with non-enum consumer types.
  • schemas/IntelligenceBrief-1.0.0.json committed and scripts/export_schemas.py --check succeeds.
  • CHANGELOG updated.
  • Coverage ≥ 80 % overall.
  • Operational follow-up (post-merge): run the WebSocket broadcaster behind a real websockets server and confirm clients receive live frames; configure a Slack webhook target against a test channel and verify delivery.

Operational Handoff

After merge operators can run the deterministic formatter pipeline end to end: the canonical JSON feed consumes SignalContext and emits bytes; the Markdown formatter renders human-readable briefs; the webhook adapter delivers to any configured destination (Slack included); the WebSocket broadcaster fans out to subscribed clients. The LLM-assisted formatter's IntelligenceBrief contract is pre-published so clients can start validating against the schema before the gated formatter lands.

Test Plan

  • uv run pytest passes locally (231 tests).
  • uv run python scripts/export_schemas.py --check passes locally.
  • uv run pre-commit run --all-files passes locally.
  • CI workflow runs green on this PR.
  • Operational: wire the WebSocket broadcaster to a real websockets.serve(...) loop and subscribe a client; confirm signal + heartbeat frames arrive.

Review Pass

  • pr-review findings addressed: 1 CRITICAL + 4 HIGH fixed in 837acc4 fix(format).
  • code-refiner simplifications applied: none actionable beyond the fixes above.

Deferred Findings

  • Review HIGH 3 (retry distinguishes retryable vs terminal exceptions): kept as-is. Generic Exception retry is intentional for a 5-attempt policy; non-HTTP errors retry as well because they are often transient (DNS, ECONNREFUSED). The 4xx drop-without-retry path does not raise, so 4xx still drops on the first attempt.

augur-format needs Jinja2 for the Markdown templates, httpx for the
webhook adapter, and websockets for the WebSocket transport. Declare
all three in the workspace member's pyproject plus depend on
augur-signals so formatters can import SignalContext and MarketSignal
directly.

config/formatters.toml mirrors phase-3 §12.2 block for block: canonical
JSON parameters (float decimals, timestamp format), Markdown render
settings (template dir, trim/lstrip blocks), webhook retry policy
(initial/max delay, max attempts, per-delivery timeout), and
WebSocket transport settings (bind, port, heartbeat interval/timeout,
per-connection buffer).

FormatterConfig composes the per-block Pydantic sub-models with
frozen, extra=forbid, and bounded numeric fields. The [json] TOML
block maps to ``canonical_json`` on the Python side via a field alias
so the attribute does not shadow BaseModel.json.
to_canonical_json is the load-bearing contract the JSON feed consumers
bind to. It turns a SignalContext into UTF-8 JSON bytes that are
byte-identical across invocations on the same input, which lets
consumers hash outputs and rely on stable comparisons.

Three invariants make the output canonical: stable key ordering via
explicit tuples (top-level, signal block, related-market block),
float rounding to six decimals (configurable via float_decimals), and
UTC timestamps with a Z suffix. Pydantic emits +00:00 offsets; the
_json_default hook normalizes those to Z so hash equality survives
the round-trip.

The formatter never silently coerces; non-serializable types raise a
TypeError rather than falling back to str or None. Callers are
expected to keep SignalContext's schema narrow enough that default
Pydantic json-mode dump produces only primitives plus datetimes.

Eight tests cover the invariants: 1000-call determinism, six-decimal
default rounding, custom float_decimals parameter, Z-suffix timestamp
normalization, top-level key ordering (via json.loads preserving
insertion order), signal block key ordering, related-market rounding,
and manipulation-flag enum preservation.
…dence, tier

derive_severity maps a MarketSignal to one of {high, medium, low}
purely as a function of magnitude * confidence scored against
per-liquidity-tier thresholds. The formula lives in code, not
configuration, so every consumer can reproduce the mapping locally
without calling the producer. Changing the thresholds requires a
schema-version bump on the IntelligenceBrief contract because
downstream routing depends on stable severity output.

Threshold semantics:
  high tier:   > 0.6 high, > 0.3 medium, else low
  mid tier:    > 0.7 medium, else low
  low tier:    always low — the sample size on low-tier reliability
               curves is too thin to justify a higher confidence
               label in a human channel.

Eight tests cover each tier, the two boundary thresholds (0.6 for
high-tier, 0.7 for mid-tier, both strict inequality), the always-low
behavior on low-tier, and the purity contract.
…dary

ConsumerEnumValidator.validate_brief is the gate every formatter
passes brief payloads through before emission. A brief whose
actionable_for list contains any value outside the ConsumerType
registry in docs/contracts/consumer-registry.md is rejected — loud,
not coerced. The validator also catches non-string members and
actionable_for fields that are not lists, both of which would
silently pass an enum check against stringified values.

validate_consumer_types is the lower-level primitive the LLM formatter
gate in the secondary layer will consume; it returns the offending
subset in the caller's input order so error messages can reference
the original list positions.

schema_check.load_schema reads exported JSON schemas from schemas/
for debug-build validation. A missing schema raises
SchemaNotFoundError rather than returning an empty dict so absent
exports surface immediately rather than masquerading as permissive
validation. The function is designed for debug and integration
pathways; production formatters skip JSON schema validation for
throughput.

Nine tests cover the happy path, single-offender flagging,
non-string members, non-list actionable_for, missing field as empty,
and both paths through load_schema.
… detail

The shared _base.md.j2 template renders the summary block (severity,
confidence, liquidity tier, detected_at), signal summary, resolution
criteria, related markets, investigation prompts, manipulation flags
(conditional on non-empty), and a provenance footer with
interpretation_mode, schema_version, and signal_id. Five per-signal-
type templates extend the base and inject detector-specific
raw_feature fields through the extra_summary block.

MarkdownFormatter wraps Jinja2's Environment with trim_blocks,
lstrip_blocks, and keep_trailing_newline set so output stays
deterministic across render calls. The template directory is
discovered via __file__ so the renderer works both in editable
installs and in built wheels.

The hatch build config explicitly includes **/*.j2 so templates ship
inside the wheel alongside the .py modules.

Eight tests cover: every signal type renders to the five distinct
templates, required fields appear in the output, the manipulation
flag block is conditional on non-empty flags, related markets render
as bullets (with fallback text when empty), investigation prompts
render as bullets (with fallback text when the list is empty), and
the formatter is deterministic across 100 invocations.
IntelligenceBrief is the payload the gated LLM formatter emits in
the secondary layer. The contract is declared here in the formatter
package because it is the formatter's output, even though the
deterministic pathway this phase does not produce briefs.
actionable_for is typed as list[ConsumerType] so Pydantic validates
the closed-enum membership at construction; the boundary validator
rechecks dynamically-constructed instances.

forbidden_token_check defaults to "passed" — the gated formatter
must run the linter and construct the brief only after it succeeds,
so the Literal documents the invariant without making the field
configurable at construction. interpretation_mode is locked to
"llm_assisted" for the same reason; every instance of this model
represents LLM-generated output.

scripts/export_schemas.py registers IntelligenceBrief-1.0.0 alongside
the four Phase-1 schemas. schemas/IntelligenceBrief-1.0.0.json ships
in the repo so consumers can validate against the contract before the
secondary formatter lands.
…n formats

WebhookFormatter POSTs SignalContext payloads to configured
destinations. Three content formats: canonical JSON bytes from the
to_canonical_json primitive, Markdown wrapped in {"text": ...} for
simple webhook consumers, and Slack Block Kit JSON for direct Slack
integrations.

The Slack block layout renders a header (signal_type | severity |
confidence), a section with the market question and resolution
criteria, optional sections for related markets and investigation
prompts, and context blocks for manipulation flags and the
provenance footer. The formatter is fully deterministic from a
SignalContext; no LLM.

deliver_with_backoff implements the retry schedule from phase-3 §6.4
(1 s initial, 60 s cap, 5 attempts). The adapter retries on 5xx and
429, drops on any other 4xx (treated as configuration errors that
retries cannot recover). Each DeliveryResult records status_code,
attempt count, and reason so the engine can track
augur_webhook_delivery_failures by target_id.

Auth headers are read from the target's auth_header_env env var at
delivery time so credentials never land in config files. The
pattern matches every other adapter in the codebase.

Six tests cover the retry-exhaustion path, 2xx happy path, 5xx
exhaustion via MockTransport, 4xx drop-without-retry, Slack block
format shape, and env-var-sourced auth header application.
WebSocketBroadcaster fans SignalContext frames out to subscribed
clients through bounded per-connection queues. Four frame types
mirror phase-3 §7: SIGNAL carries the canonical SignalContext JSON
as its payload; HEARTBEAT fires at the configured interval; STORM_START
and STORM_END map onto the dedup layer's storm transitions so clients
can switch rendering behavior without polling the server.

The broadcaster is an in-process primitive that adapts to a real
websockets server easily: the deployment layer wraps each accepted
connection in a ClientSubscription, forwards subscription.queue.get()
to the wire, and drops the subscription on client disconnect. The
in-process layer keeps the algorithmic surface testable without
spinning up real TCP sockets.

Under pressure the broadcaster drops the oldest queued frame to
preserve timeliness — matching the dedup doc's rationale that the
latest signal is usually the most informative. The dropped counter
on each subscription lets operators surface slow-client incidents.

HeartbeatScheduler is a small helper that answers "should the
broadcaster emit a heartbeat now?" from a caller-supplied ``now``;
the engine owns the scheduling loop, which keeps every timing
decision deterministic and testable.

Nine tests cover: signal-frame payload roundtrip through canonical
JSON, heartbeat/no-payload shape, storm frame type mapping, Z-suffix
timestamps, fan-out to subscribers, consumer-type filter, oldest-drop
under full queues, heartbeat scheduler boundary, and buffer-size
validation.
ConsumerRegistry.from_toml reads config/consumers.toml (shipped in
the workspace bootstrap) and exposes the per-category consumer
routing as typed ConsumerType tuples. Unknown categories fall
through to the ``default`` entry, which mirrors
docs/contracts/consumer-registry.md §Routing's fallback rule
without hardcoding the dashboard default into code.

SignalRouter maps a SignalContext to the consumer set by looking up
the market's category in the registry. When the context's
interpretation_mode is LLM_ASSISTED, consumers that have not
explicitly opted in are reported under suppressed rather than
consumers — the deterministic pathway in this phase does not exercise
this branch, but the gated secondary formatter will. The default
opt-in set is {DASHBOARD} per the consumer-registry document's
§Why Each Consumer Exists.

Six tests cover: registry TOML load against the shipped
config/consumers.toml, fall-through to default for unknown
categories, default consumers on unregistered markets, per-market
category routing, llm_assisted suppression surfacing, and
register_market_category idempotency.
CRITICAL: _round_floats now sorts nested dict keys so producers with
variable raw_features insertion order (the dedup fingerprint and
cluster-merge paths that conditionally add merge_provenance /
cluster_member_signal_ids) emit byte-identical JSON for the same
logical payload. The outer payload was already protected by
CANONICAL_KEY_ORDER; nested dicts were not. The 1000-call test could
only detect drift within a single process; this fix covers
cross-producer drift that the review called out.

HIGH: deliver_with_backoff now returns (result, attempts) so
DeliveryResult.attempts reflects the actual retry count instead of
hardcoding policy.max_retries on failure and 1 on success. Webhook
delivery surfaces DeliveryRetryExhaustedError.attempts and last_error
verbatim into the telemetry record.

HIGH: HeartbeatScheduler is no longer @DataClass(frozen=True) with a
mutable list field; it is plain slots with datetime | None for
last-sent. frozen + mutable contents was accidental and semantically
misleading.

HIGH: WebSocketBroadcaster.publish now serialises the full-queue
check-and-drop under a per-subscription asyncio.Lock. Concurrent
publishers previously could each drain a slot from the same full
queue, dropping multiple frames when oldest-drop intended to drop
exactly one.

HIGH: WebhookTarget drops the consumer_types and accepts_llm_assisted
fields that had no call site today. SignalRouter owns consumer
gating; the LLM formatter's opt-in arrives when the gated formatter
lands. Per CLAUDE.md no abstractions without two concrete call sites.
@Mathews-Tom Mathews-Tom merged commit deba233 into main Apr 17, 2026
2 checks passed
@Mathews-Tom Mathews-Tom deleted the feat/deterministic-formatters branch April 17, 2026 08:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant