loom-ui
@loom-ui/learning
The learning loop, host side (U11). A planner chooses; viewers react; the reactions become ONE
bounded reward on the decision that produced what they saw; and the log says which choice was
better. loom-ui adds no learner and no estimator: the choice is a loom decision (decideWithPolicy,
ADRs 0078-0080), the reward is a late runtime.recordOutcome (ADR 0116), the comparison is
@loom/learning’s evaluatePolicy (SNIPS) and promotionGate. This package is the glue that has
to distrust the browser. It is server-side and optional; hydration’s client guards name it
among the packages a client graph must not reach.
Install / import
// package.json of a consumer checked out beside this repo (the packages are not published)
"@loom-ui/learning": "link:../loom-ui/packages/learning"
Entry point: @loom-ui/learning (server-side only; Node, CommonJS and ESM).
How it fits together
renderer (opt-in) host loom
SignalCollector ─batch─▶ SignalIngestor ─vote─▶ VoteLedger (append-only ─▶ settleRewards ─▶ runtime.recordOutcome
(core/signals) cost, shape, tenant, VoteLog, CAS) fold = (close = CLAIM, (late, one key per
node, saw-it, names 1 vote per frozen summary) decision)
▲ viewer
ReferencePlanner variants ────────┘ DecisionLinks: (tenant, version) ─▶ (execution, decision) ─▶ evaluateVariants
(decideWithPolicy, propensity) (evaluatePolicy)
The signal model
@loom-ui/core/signals (see core’s README): exposure, engagement (click/expand counts, dwell
buckets sent once per view), feedback (1-5), outcome (token + boolean), batched per surface
version, v: 1, at most 64 per batch. No text, no PII, no raw timing. A restricted node is reported
restricted: true under its opaque alias (U10c); the host collapses every restricted signal of
a viewer onto one version-level pseudo node, so an alias carries no weight and no real id is ever
needed.
Decision linkage
ReferencePlanner with variants records the variant choice as a learnable decision
(PlanResult.report.variant.decisionId; propensity in the log). plannerProvenance(result) stamps
it as Provenance.policyDecisionId, so the U12 ledger already says which decision wrote which node.
The host registers what it knows per (tenant, experience, version):
const links = new DecisionLinks();
links.register(experienceId, versionHash, linkFromLedger(ledger, {
executionOf: (decisionId) => executionByDecision.get(decisionId), // the host ran the turn
tenant, // REQUIRED
visibleTo: (viewer) => nodesCompiledVisibleFor(viewer), // the host's own compile
}));
A node signal credits that node’s decision; version-level facts (a surface rating, an outcome) and
restricted nodes credit every decision of the version; a node the link does not list, or that is not
visible to this viewer, earns nothing and answers identically (unknown-node), so the host is
not an existence oracle for hidden nodes. A link without a tenant cannot be registered, and the
link is looked up by tenant: the same experience id in two tenants is two links.
Ingesting untrusted signals
const ledger = new VoteLedger({ log, pseudonymKey, clock, outcomes: ["task-approved"] });
const ing = new SignalIngestor({ links, ledger, clock, exposure: seenLogExposure(seenLog, { versionIsEnough: false }) });
const result = await ing.ingest({ viewer: { id: session.userId, tenant: session.tenant }, raw: req.body });
res.status(204).end(); // ALWAYS the same bare answer; keep `result` in server logs
Answer every well-formed POST with the same bare 204 and keep IngestResult server-side. Its
reasons (unknown-node, unexposed, unknown-outcome…) are for your logs; echoing them lets a
client probe what exists, what it may see and which names are registered.
The viewer is the host’s authenticated session, keyed by tenant and id; a viewerId in the
payload is ignored. ingest also takes an optional audience ("broadcast" for a wallboard surface), passed
to visibleTo(viewer, audience) and the exposure check. In order: cost (every call, accepted or refused, is charged to the viewer’s
rate budget before it is parsed, its claimed signal count, at least 1; rateLimit default 512 per
rateWindowMs default 60 s; an over-budget call is refused whole and not remembered) ->
shape (validateSignalBatch) -> view (registered for this tenant; a foreign tenant’s view
and an unregistered one answer alike) -> node (listed + visible; a restricted flag on a node
the link lists is refused) -> saw it -> outcome names (only those registered with the
ledger; a client cannot mint outcomes). Replays of a batchId are refused fast per viewer and
ignored by the fold.
Exposure is required. exposure is an authoritative ExposureCheck or the explicit string
"client-claimed" (demos and tests). Use seenLogExposure(seenLog, { versionIsEnough: false })
wherever nodes are data-bound: the server-recorded view of this exact version, with the node among
the recorded entries (an erased viewer or an unreadable log answers no). Static nodes leave no entry
in a SeenLog; for those the default versionIsEnough: true accepts a recorded view of the
version. The query carries the viewer’s tenant, but the real SeenLog is keyed by viewerId
alone: it MUST be tenant-keyed by the host (one SeenLog per tenant, or record views under
viewerIdOf({ tenant, viewerId }), which seenLogExposure accepts). "client-claimed" stops an
engagement on a node the client never claimed to show, and nothing more.
One bounded vote per viewer (the vote ledger)
Accepted signals are appended to an append-only log per decision (VoteLog: append iff the
tip is n, read; structurally @loom-ui/lineage’s LineageStore, so
eventStoreLineageStore(eventStore) over SQLite/Postgres is the durable reference and
InMemoryVoteLog the single-process one). The score is a fold of the log, and the fold reduces
each viewer to one vote: engagement = engaged nodes / exposed nodes (a click, an expand or dwell >=
5 s counts; counts capped), the latest rating, outcomes by registered name, at most 64 nodes and 128
batches per viewer, at most 100 000 viewers per decision (overflowViewers counts the rest, in log
order). Pooling is a mean of per-viewer terms in [0,1], so no client-controlled number multiplies a
reward. A replayed batch is ignored (an old rating cannot override a newer one); an engagement or
rating counts only after the same viewer’s exposure of that node.
Because there is no in-memory “once” ledger, there is nothing to evict: the per-decision entry cap
(maxEntriesPerDecision) makes ingest refuse (capacity) rather than forget. The in-memory log
is a single ingesting process; any number of replicas can share a durable VoteLog, and the log’s
CAS interleaves their appends. Viewers appear in the log only as viewerKey = HMAC(pseudonymKey,
[tenant, id]) (JSON-encoded, so no character can shift the boundary); destroy the key and the
votes can no longer be tied to a person.
Appends. Appends to one decision are serialised in-process (a promise chain per stream, dropped
when idle), so one process never loses a CAS to itself; against other replicas a lost CAS rebuilds
the stream’s state from the durable log and retries with jittered backoff (64 attempts). If the log
stays contended the result is the typed contended (an ingest rejection reason; settleRewards
leaves that decision deferred and carries on with the rest), never a throw. A vote is checked
against the stream’s own state at the append, so a vote that lands after a CLAIM is closed and
never counted as accepted.
Per-viewer budget (no starving). Per decision, a viewer may append at most maxBatchesPerViewer
(default 128) votes, counted from the durable log (so a restart or another replica cannot reset
it); past that only that viewer is refused (viewer-capacity). The last quarter of
maxEntriesPerDecision is reserved for first votes: a returning viewer is refused
(viewer-capacity) once the log holds 75% of the cap, while a viewer with no vote yet can still
vote until the cap itself. So viewers cannot fill the log between them while a new one is turned
away, short of genuinely running out of capacity. The reserve is only as strong as viewer identity:
ingest({ viewer }) MUST be the host’s authenticated session (tenant + id), never a client-supplied
id. A principal holding many identities, or a host that derives ids loosely, can spend the reserve
with fresh viewers (each still capped at maxBatchesPerViewer, the pool at maxViewers). Cost: the
ledger caches state for up to 1024 decisions, each with a per-viewer count; a lost CAS
(cross-replica contention only) re-reads and re-folds that decision’s log, so size
maxEntriesPerDecision and maxViewers to what one decision really needs.
pseudonymKey rotation. A rotated key gives each viewer a NEW viewerKey, so a viewer who
voted under the old key and votes again under the new one counts as two viewers in a still-open
decision. Rotate only between decisions (after their close), or keep accepting the old key for the
open ones.
Rewards: settle once, at a close
settleRewards({ ledger, runtime, decisions, reward, rewardId, closeId, authorize, clock }) closes
each decision at a host-declared moment (an experiment end, a TTL after the execution finished). The
close appends a CLAIM to the decision’s log; every reader folds only the votes before the first
CLAIM, so two replicas settling at once compute the same frozen summary and score, and the
kernel’s (decisionId, key) idempotency absorbs the repeat. The score is a pure function of the
frozen summary AND the reward function, so rewardId must name the weights (defaultReward({ outcome: 5 }) is a different rewardId from the default): specHash covers only the ledger’s
outcome names and caps. A second settler with different weights under the same rewardId computes a
different score, and the kernel refuses it loudly (a typed permanent refusal, reported in refused)
rather than overwriting. One recordOutcome per decision, key ui:<rewardId>:<closeId>:<specHash>
where closeId is the CLAIM’s (the first closer’s), so settlers passing different close ids
still record under one key. defaultReward is the weighted mean 1 / 2 / 3 of engagement, rating and
outcome rate over per-viewer terms, in [0,1].
Why once and not per window: trajectories averages a decision’s outcomes with equal weight, so
many partial windows would let a 1-viewer window weigh the same as a 10 000-viewer one. Windows are
not offered; if you need periodic results, use a new decision (a new planner turn) per period. Give
a decision ONE rewardId/closeId for life.
- Principal.
authorize(ref)returns who records it. Prefer a reward-pipeline principal grantedannotatefor its tenant (executionAccessPolicy({ rules: { annotate: "tenant" } })) or a system grant over impersonating each owner; ADR 0116’s default isannotate: owner, under which any other principal is refused (tested). - Terminal only.
recordOutcomerefuses a running execution (ADR 0116). Run learning-relevant planner turns in short executions (plan, record, finish), not inside a long-lived agent loop. A not-yet- terminal decision isdeferredand retried; pastmaxDeferralMs(default 7 days, aged from the CLAIM) it isabandoned, marked settled and reported. A transient failure (anything not typed permanent) is retried up tomaxAttemptsper process, then abandoned; onlyExecutionDeniedErrorandOutcomeRefusedErrorare treated as permanent refusals (marked settled, reported inrefused). Not-terminal detection is by error name, isolated inisNotTerminal(TODO: a loom typed check).
UI evaluation
evaluateVariants(events, { rewardId, thresholds }) takes exported events (any EventStore.read;
for a compacted execution pass evidenceEvents(state, events) from @loom/learning, not the raw
tail), keeps the variant decisions and only the outcomes this pipeline recorded (key
ui:<rewardId>: AND kernel-stamped; an unrelated CSAT outcome on the same decision is excluded:
tested), and evaluates “always variant V” for each V with evaluatePolicy gated by promotionGate.
The unit of an execution is its first variant decision’s. It returns rows (per-variant
reports, best first), reasons, and a winner only if one passes the gate and the logging policy explored: if every
variant choice was logged with propensity 1 (UCB, a greedy policy) no estimate is unbiased and no
winner is named unless allowDeterministicLogging: true. loom’s default gate demands 1000 steps; a
small trial passes its own thresholds.
Units and erasure
The planner’s unit is a pseudonym: derive it server-side with unitAlias(key, userId) (HMAC),
never from a client, never a raw id or email. It is recorded in the decision log for good;
destroying the key severs it from the person, but the log still ties one alias’s decisions together.
viewerKey in the vote log is a separate HMAC domain under the same kind of key.
Reference example spec: planner-learning (for loom-examples)
Not in this repo (examples live in the sibling loom-examples). Proposed shape, headless and
offline:
- Two planner variants,
flatandtabs(REFERENCE_CONTAINER_ROLESplus “prefer grouped tabs”), assigned bystableBucketPolicyover 200 synthetic users whoseunitisunitAlias(key, userId), with a scripted model. - Each user’s turn runs in its own short, owner-attributed execution;
plannerProvenance+linkFromLedgerregister the version’s decisions. - Simulated viewers drive a
SignalCollector; batches go through aSignalIngestorwithseenLogExposureinto aVoteLedgerovereventStoreLineageStore. settleRewardsat the close records one pooled late reward per decision as a reward-pipeline principal; a second ledger instance over the same log shows the same summaries.evaluateVariantsover the exported logs prints the per-variant SNIPS table and names the winner (tabs); a stricter gate names none and says why; a UCB-logged run names none and says “deterministic”.- Optional: swap in
epsilonGreedyPolicywith the winner asgreedyand show the new propensities in the log.
See also
- ../planner/README.md — the planner variants this package evaluates.
- ../lineage/README.md — the
SeenLogand event stores the vote log builds on. - ../core/README.md — the
@loom-ui/core/signalsvocabulary. - ../../README.md — root overview.
Source: packages/learning/README.md in the loom-ui repository.