kaldamus entity reference · Factor · EventFactor · Dependency · Observation

Factor discovery

Every event that clears the priority gate gets a fresh LLM read of its causal drivers. What survives across runs isn't that reading — it's the canonical Factor those readings keep converging on.

01

Two entities, one call

EntityidHoldsWrite rule
Factorslugify(canonical_name)canonical_name · description · category · grain / geo · indicators[]first-write-wins
EventFactor{event_id}::{factor_id}just the two ids + generated_atreplaced wholesale
Concept
Example
One discovery call returns two kinds of row: a canonical, deduplicated Factor (the causal claim) and a disposable EventFactor join (this event asserted it this run).
1 call ≤6 factors + join rows
All the interesting state lives on Factor, in which fields may change after the first write; EventFactor is only ids and a timestamp.
the join row carries no nuance fields
category, grain, geo are never asked as free text — name is de-kebabed in code, the others inherited or coerced at write time.
name = de-kebab(canonical_name)
02

How update-factors runs

flowchart TD
    classDef srcNode fill:#16233b,stroke:#5a8fd4,stroke-width:1.5px,color:#dfe7f5;
    classDef factorNode fill:#241d0c,stroke:#c9a84c,stroke-width:1.5px,color:#f3e6bd;
    classDef decisionNode fill:#12151f,stroke:#5a6178,stroke-width:1px,color:#c9cbd6;
    classDef llmNode fill:#161822,stroke:#e0e0e0,stroke-width:2px,color:#f0f0f0;
    classDef deadEnd fill:#12151a,stroke:#3a4058,stroke-width:1px,color:#7a8296,stroke-dasharray:3 3;

    A["events.json"]:::srcNode --> B{"priority gate
sorted, capped"}:::decisionNode B -- below --> Z1["excluded this run"]:::deadEnd B -- selected --> C{"EventFactor set fresh?
all rows ≥ enriched_at, within TTL"}:::decisionNode C -- fresh --> Z2["skipped"]:::deadEnd C -- "stale / forced" --> D["one LLM call
flat_event_dict + dedup hint"]:::llmNode D --> E{"response parses?"}:::decisionNode E -- no --> F["stats stamped,
nothing written"]:::deadEnd E -- "yes, ≤6 factors" --> G["id = slugify(canonical_name)
folded through alias map"]:::llmNode G --> H{"id already on disk?"}:::decisionNode H -- yes --> I["bump last_updated_at only"] H -- no --> J["insert new Factor row"] I --> M["replace EventFactor set:
delete old, insert new"] J --> M M --> N["reconcile_times_referenced"]:::factorNode N --> O["factors.json
event_factors.json"]:::factorNode

Every run of update_factors.py, one event at a time. Gold = the canonical corpus; blue = the raw event input.

Concept
Example
One pass over every event past the priority gate: select, freshness-check, one model call, then a dedup-and-persist step that never mutates a factor's meaning after first write.
priority-gated one LLM call each
Freshness is cascade-first: one stale join row re-triggers discovery for the event's whole factor set, not just that row.
1 stale EventFactor full set regenerated
The dedup hint is two slices — corpus-wide top-N by reference count, plus per-event lexical matches on the event's own title + rules.
frequency slice + lexical slice
Identity is folded exactly once, when the id is minted, through a curated alias map — the only auto-merge authority (no similarity-threshold merges anywhere).
slugify(name) alias map
03

How state is managed

Row / fieldWritten byChanges again when…
canonical_name, description,
category, grain, geo
the first successful discovery of that idnever automatically — only a manual delete + rerun
last_updated_atevery successful re-discovery hitbumped each hit; left untouched by backfills, so age reporting stays honest
times_referencednobody, directlyrecomputed from event_factors.json every run — never incremented
EventFactor set (one event)that event's most recent callreplaced wholesale next run — old set deleted first, nothing merged
Concept
Example
Three fields, three mutation regimes — and none is "overwrite on every run." A Factor's description is written by exactly one model call, ever.
a bad first description is permanent until a manual delete
times_referenced is recomputed from the join table each run, never incremented — an earlier increment-on-write scheme double-counted re-discoveries.
recompute is idempotent + self-healing
An empty LLM result is read differently per job: for factors it's a failed call (keep the prior set); for dependencies it's a real "none."
empty factors prior set kept, only stats stamped
04

Transient, or permanent?

stateDiagram-v2
    direction LR
    classDef factorState fill:#241d0c,stroke:#c9a84c,stroke-width:1.5px,color:#f3e6bd;
    classDef warnState fill:#231a0c,stroke:#c98500,stroke-width:1.5px,color:#f0c968;
    classDef deadState fill:#12151a,stroke:#3a4058,stroke-width:1px,color:#8892a4;

    [*] --> Stable: first LLM emission
of this canonical_name Stable --> Stable: re-discovered by another event
(first-write-wins) Stable --> Orphaned: last referencing EventFactor
retired elsewhere Orphaned --> Stable: asserted again by a
new or returning event Orphaned --> Purged: --purge-orphans
(manual, not in update-all) Stable --> Merged: merge_factors.py folds id
into a curated target Purged --> [*] Merged --> [*] class Stable factorState class Orphaned warnState class Purged deadState class Merged deadState

A Factor row's full lifecycle. There is no automatic path out of Orphaned — it requires a human to run the purge.

Concept
Example
Split cleanly across the two entities: the canonical Factor is built to last; the EventFactor evidence that it applies to a given event is rebuilt from scratch each cycle.
Factor indefinite · EventFactor ~one TTL window
Nothing routine deletes a Factor--purge-orphans is manual and not wired into update-all — so zero-join factors accumulate.
most of the table is zero-join "zombie" rows
An orphaned factor has no automatic path back: a human runs the purge, or a new event re-asserts it.
last join retired Factor kept, unreferenced
05

Factors that outgrow their category

Economics Entertainment Legal Finance/Crypto Sci/Tech Politics Sports Health Other media-coverage-intensity

Illustrative: one Factor, persisted category: "Legal" (the gold-ringed chip — set once by whichever event discovered it first), joined to events sitting under every other category. None of those categories feed back into the row.

Concept
Example
A factor's category is provenance, not membership — set once from the event that discovered it first, then never updated, even as the factor joins events across most other categories.
persisted "Legal", reaches events in ~nine categories
The real cross-category reach lives only in the EventFactor join graph, which the Factor row never summarizes.
one Factor row, hundreds of joins
grain/geo are the deliberate version: the LLM self-declares event-specific / macro-country / macro-global, feeding the news ranker's hub penalty.
macro-global = rates, oil, global equities
grain is under-applied — a backfill promotes high-degree factors to macro-global, but a heavily-joined factor can still sit tagged event-specific.
degree ≥ 20 macro-global (backfill often unrun)
06

The two satellites: dependencies and measures

EventDependency fieldNote
id{event_id}::{depends_on_event_id} — composite by construction
kind"hard" (must occur first) or "soft" (materially enables)
descriptionthe LLM's stated reason for the link
generated_atthis run's timestamp
Concept
Example
The same job pair produces two more entities: EventDependency ("what has to happen first?") and FactorObservation ("what's the real-world number?").
two satellites, one pipeline
EventDependency runs a closed-world pass — the candidate list of other open markets is the only permitted value; off-list, self, or empty is dropped.
depends_on must be a listed candidate
A hard dependency is falsifiable: it implies P(event) ≤ P(depends_on), checked per edge by the graph builder.
KXFDAAPPROVE-GEDKXGEDATOLISIB-26 (hard)
Here an empty result is a real answer — most events have no traded precondition — so [] replaces the set (mirror of §3).
no precondition [], slot marks "checked, none"
FactorObservation is the one network stage: one row per cited (source, series_id) with a value, a short rolling buffer, and a computed trend.
a FRED series value + ≤24-point buffer + slope
Most cited indicators don't resolve to a value; a dead citation feeds back to strip newly-emitted ones. Trend ≠ correlation — it weakens a claim, never confirms it.
Google Trends no adapter (bot-blocked)

See also