feat(format): deterministic JSON, Markdown, webhook, and WebSocket formatters#5
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Delivers the deterministic output formatters that consume
SignalContextand 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. TheIntelligenceBriefschema contract is published here for the gated secondary formatter that lands in the next phase.What Changed
to_canonical_jsonwith stable key ordering, six-decimal float rounding, Z-suffix UTC timestamps.derive_severityis a pure function ofmagnitude * confidence * tierreturning{high, medium, low}._base.md.j2; templates ship inside the wheel.ConsumerEnumValidatorrejects briefs with unknownactionable_forvalues;load_schemareads exported schemas.WebhookFormatterwith JSON, Markdown, and Slack Block Kit formats; retry on 5xx/429, drop on 4xx; env-sourced auth headers.WebSocketBroadcasterwithSIGNAL/HEARTBEAT/STORM_START/STORM_ENDframes; oldest-drop under pressure.ConsumerRegistry.from_tomlloadsconfig/consumers.toml;SignalRoutermaps contexts to consumer sets with suppression forllm_assistedmode.schemas/IntelligenceBrief-1.0.0.jsonexported.config/formatters.tomlwith 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
schemas/IntelligenceBrief-1.0.0.json. No changes to the four Phase-1 schemas.Quality Gates
uv run ruff check .cleanuv run ruff format --check .cleanuv run mypy --strict src/clean (106 source files)uv run pytest --cov-fail-under=80passes; 231 tests, 88.0 % coverageuv run python scripts/export_schemas.py --check)datetime.now()AST guard passesuv run pre-commit run --all-filespassesDefinition of Done
consumer_type, drops oldest on full queue.schemas/IntelligenceBrief-1.0.0.jsoncommitted andscripts/export_schemas.py --checksucceeds.websocketsserver 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
SignalContextand 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'sIntelligenceBriefcontract is pre-published so clients can start validating against the schema before the gated formatter lands.Test Plan
uv run pytestpasses locally (231 tests).uv run python scripts/export_schemas.py --checkpasses locally.uv run pre-commit run --all-filespasses locally.websockets.serve(...)loop and subscribe a client; confirm signal + heartbeat frames arrive.Review Pass
pr-reviewfindings addressed: 1 CRITICAL + 4 HIGH fixed in837acc4 fix(format).code-refinersimplifications applied: none actionable beyond the fixes above.Deferred Findings
Exceptionretry is intentional for a 5-attempt policy; non-HTTP errors retry as well because they are often transient (DNS, ECONNREFUSED). The4xxdrop-without-retry path does not raise, so 4xx still drops on the first attempt.