kaldamus entity reference · NewsArticle · NewsBrief · NewsDispatch
A rolling scan of the open web turns into one wire-desk article a day. What survives between the three stages isn't prose — it's a persisted trace an eval can recompute without trusting the model that wrote it.
| Entity | id | Stage & payload |
|---|---|---|
| NewsArticle | sha256(url)[:16] | scan + classify — url/domain/provider, what_happened digest, matches[] (FactorMatch) |
| NewsBrief | UTC date | assess + rank — lede, ranked stories[] (each with factor_moves + impacts[]), gaps[] |
| NewsDispatch | = brief id (UTC date) | synthesize — headline/dek, link-free body_source → rendered body_md, ordinal citations[] |
FactorMatch against the existing factor vocabulary.ten-year-treasury-yield · move −1.0 · conf 0.9
flowchart TD
classDef srcNode fill:#16233b,stroke:#5a8fd4,stroke-width:1.5px,color:#dfe7f5;
classDef briefNode fill:#241d0c,stroke:#c9a84c,stroke-width:1.5px,color:#f3e6bd;
classDef detNode fill:#12151f,stroke:#5a6178,stroke-width:1px,color:#c9cbd6;
classDef llmNode fill:#161822,stroke:#e0e0e0,stroke-width:2px,color:#f0f0f0;
F["news_articles.json
+ factors.json, events.json"]:::srcNode --> S["Stage S — scan
keyless RSS + GDELT · no LLM"]:::detNode
S --> C["Stage C — classify + match
LLM, batched · closed-world"]:::llmNode
C --> ART["news_articles.json
matched + off-beat"]:::srcNode
ART --> M["Stage M — impact graph
deterministic: factor→event edges"]:::detNode
M --> A1["Stage A1 — assess
LLM qualitative ∥ deterministic pressure_logit"]:::llmNode
A1 --> A2["Stage A2 — corroborate
lookup vs cached FactorObservation"]:::detNode
A2 --> B["Stage B — compose
one LLM call: lede + link-free prose"]:::llmNode
B --> BRIEF["news_briefs.json"]:::briefNode
BRIEF --> D["Stage D — synthesize
separate job, reads the brief only"]:::llmNode
ART -.cited articles.-> D
D --> DISP["news_dispatches.json
replaces same-date row"]:::briefNode
Blue = the article corpus; gold = the two dated artifacts. Bright-bordered nodes are the three places an LLM actually writes: C classifies, B composes the brief, D synthesizes the dispatch — everything else is a lookup or arithmetic.
factor_id that already exists — an off-vocabulary article is still retained, just with an empty matches list, feeding the brief's gaps.FactorObservation rows already on disk.| Row | Written by | Changes again when… |
|---|---|---|
| NewsArticle | the scan that first surfaces the URL | upserted in place if the same URL resurfaces (id = url hash); else ages out by retention |
| NewsBrief (whole) | one briefs run for that UTC date | never merged — a same-date rerun replaces the row outright |
| NewsDispatch (whole) | one dispatch run for that UTC date | upsert replaces by id — "double-fire replaces, never duplicates" |
BriefStory.prose is link-free by contract: it cites sources as ordinals, and the renderer joins ordinals → real URLs — so a fabricated citation is structurally impossible in the rendered artifact.[1], never a URLhttp:// / https:// substring in the model's output outright — neither model ever has a channel to write a link.body_source → rejected
stateDiagram-v2
direction LR
classDef briefState fill:#241d0c,stroke:#c9a84c,stroke-width:1.5px,color:#f3e6bd;
classDef deadState fill:#12151a,stroke:#3a4058,stroke-width:1px,color:#8892a4;
[*] --> Scanned: first surfaced by a scan query
Scanned --> Retained: accepted (matched or off-beat)
Retained --> Retained: same URL re-surfaces (upsert)
Retained --> Aged_out: fetched_at outside
article_retention_days at prune time
Aged_out --> [*]
class Retained briefState
class Aged_out deadState
A NewsArticle row's lifecycle. Unlike Factor's "Orphaned" state, aging out is fully automatic — pruning runs at the end of every scan, no manual step.
| Entity | Retention rule |
|---|---|
| NewsArticle | time window on fetched_at, pruned every run |
| NewsBrief | count cap — newest N by id kept |
| NewsDispatch | count cap — newest N by id kept |
events.json, which only ever grows — each has its own bounded retention rule.0.5 × 0.35 × −1 × −1.0 = 0.175salience × elasticity × sign × factor_move, sums them, and requires the total to match the stamped pressure_logit within a small epsilon.impact_arithmetic_reproducibleFactorObservation rows — it either finds a fresh row or stamps no_data, never inventing an id.no_dataFactorMatch can only name a Factor that already survived discovery — no pipeline mints a new causal node mid-story.