R3 slice (b): the rest of hull #3. Two derived brief-time signals — first-mover (recent + unsurfaced -> ranked up) and saturation (surfaced >= N prior days -> ranked down) — computed from already-persisted fields. SB1 derived (no schema bump, SCHEMA_VERSION stays 4); SB2 R3a composite stays the primary sort key, the overlay is a within-tier cmp refinement. Zero new source/test files; counts 29/19/27; ASSERT floor 111 -> 117. Three Opus reviewers (scope-guardian / brief-reviewer / plan-critic) folded: warming badge gated at >=2 (preserves the R3b contract), disagreement ordering fixture (true RED), saturationAt clamp, ageDays>=0 guard, fresh->neutral rename, SSOT one-line note, nightly-thresholds known limitation, cite fixes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011vmzxpsFpc8q19LaogAWLD
36 KiB
Brief — RE-R3d: temporal overlay — first-mover + saturation (R3 slice b)
Slice: RE-R3d (research-engine rung-2, R3 slice (b) in the operator's
(a)→(c)→(b)→(d)→(e)sequence — the doc is numbered r3d by creation order, the concept is slice (b)). It closes the rest of hull #3 (substrate §1): the store-schema fields a brief ranks on wererelevance(✅ R3a) ·status(✅ R3b) · first-mover · saturation ·angle.angleis already a scored dimension (score.dimensions.angle, weight 15 %/20 % —score.ts:24/:32), folded into the R3a composite; so the rest of #3 is exactly saturation + first-mover, confirming §5's framing. Predecessor: RE-R3a (persisted relevance composite — the PRIMARY within-bucket rank key,brief.ts:102)
- RE-R3b (the seen-log: per-day-idempotent
surfacedCount/lastSurfacedAt,types.ts:71-78, whose comment names this slice — "the temporal foundation slices (c)+(b) read") + RE-R3c (the autonomous trigger that makessurfacedCountaccumulate day-over-day without the operator running anything — the dependency that makes saturation a real signal, the load-bearing reason (b) follows (c)). Substrate:docs/research-engine-concepts.local.md§1 hull (3) ("store-schema mangler felt en brief rangerer på (relevance/first-mover/saturation/angle/status)") + §B4 ("freshness-vindu … + dedup-state (append-only seen-log → ikke re-overflate samme sak)" — the seen-log IS the saturation substrate). R3d turns the seen-log + the publish/capture dates into a live temporal overlay the brief ranks on. The core decision (operator-confirmed, AskUserQuestion 2026-06-26 — baked):
- SB1 — derived at brief time, NOT a stored field. first-mover/saturation are pure functions of fields the store ALREADY persists (
publishedAt/capturedAt→ age;surfacedCount→ repeat-exposure) + the injectedtoday— exactly likeageDays/effectiveDateare derived onBriefEntry, never stored. NoSCHEMA_VERSIONbump (stays 4); noBRIEF_SCHEMA_VERSIONbump (stays 1). The signal recomputes every run, so it can never go stale (a stored field would, assurfacedCountgrows). Respects the discipline that an avledbar value is not persisted (ageDaysis derived;scoreis persisted only because it is a frozen model judgment —types.ts:54-64).- SB2 — refine recency WITHIN the composite tier; do not override it. The R3a relevance composite stays the PRIMARY sort key (
brief.ts:103). The temporal signal enterscmpas a NEW key after pillar-overlap, beforeeffectiveDate— a richer recency class than the raw date it sits in front of. A saturated-but-higher-composite trend still outranks a fresh-but-lower one (composite dominates across tiers); the overlay only re-orders within the same (composite, overlap) tier. Honors "felt en brief rangerer på" without shadowing the SSOT-pinned relevance score. TDD-order (two-phase RED, light-Voyage discipline, inherited from R3c): Phase A —temporalSignalis a NEW named export of the EXISTINGbrief.ts; under Node16 ESM a missing named import throws at module-load (everybrief.test.tstest would error, not assert), so land a non-throwing stub (temporalSignal → {tier:"fresh", …},BriefEntry.temporalpopulated by it) FIRST, then record value-assertion RED against it (the stub returns "fresh" always → the first-mover/saturated/ordering/token assertions fail on values, true assertion-RED). Phase B — the CLI flag tests are value-RED against the existingbriefhandler (the new flags are silently ignored byparseFlagstoday → tuned-threshold behaviour is unchanged → RED). See plan Step 1.
1. Operator decision context (2026-06-26)
The research engine is Tier-1 (operator, 2026-06-23). R1→R3c built the deterministic spine, the trend's life
after capture, and the autonomy that runs it: item-schema + triage (R1) → capture bridge (R2a) → dated
morning brief + surfacing (R2b) → persisted relevance + composite ranking (R3a) → status lifecycle + seen-log +
re-score (R3b) → autonomous trigger + headless entry (R3c). The brief now regenerates itself every morning
(R3c), so surfacedCount accumulates day-over-day on its own — but the brief still treats a trend the operator
has seen surfaced for five straight days identically to one captured an hour ago, as long as their frozen
relevance composites tie. The temporal axis the seen-log records is logged but unread by the ranking.
R3d closes the rest of hull #3 — the first-mover and saturation signals — which the operator chose as
slice (b) of the full-R3 build-out (2026-06-24, "ALLE gjenstående R3-slices … i rekkefølge (a) → (c) → (b)
→ (d) → (e)"). It is sequenced after R3c for a load-bearing reason: saturation reads surfacedCount, and
surfacedCount only accumulates autonomously because R3c now fires the brief daily. (c) makes (b) meaningful;
without the daily trigger, the seen-log would only grow on the days the operator happened to ask for a brief.
The concrete value — keeping a frozen composite honest over time. R3a froze the relevance composite at first
capture (types.ts:54-59: "first-sight, never updated on re-capture"). A trend captured fresh scores
timing 9 ("you'd be among first") → a high composite → "Immediate" → sorts top (brief.ts:103). Six days and
five surfacings later it is stale and over-exposed, yet the frozen composite still says "Immediate" and still
sorts top. R3d is the live temporal overlay that demotes it — without mutating the stored composite (which
stays the SSOT-pinned, re-score-on-re-capture value R3b owns). surfacedToken (brief.ts:154) is already a
hint of this — the · sett Nx marker explicitly labelled "Not the saturation SCORING of slice (b)". R3d
promotes that placeholder hint into the real, ranked signal.
2. The gap — grounded in code
- The seen-log is written but never ranked on (hull 3, saturation). R3b records
surfacedCount/lastSurfacedAtper trend (store.ts:187-202,types.ts:71-78) and renders a display hint (brief.ts:154-157, the· sett Nxtoken at>=2), butrankForBrief's comparator (brief.ts:102-107) never reads it — sort order iscomposite → overlap → effectiveDate → title → url. A trend surfaced ten times sorts identically to one surfaced zero times at the same composite+overlap. - Recency is read coarsely (hull 3, first-mover). The comparator's only temporal key is
effectiveDate(brief.ts:105) — a raw date tiebreaker. There is no notion of "this is genuinely fresh AND uncrowded — you'd be first" vs "this is just the most recent of a stale set". The capture-timetimingdimension (score.ts:23, kortform 20 %) captures a first-mover judgment, but it is frozen at capture (R3a) — it cannot reflect that the same trend is no longer fresh a week later. angleis already covered (hull 3, no work needed). Hull #3 listsangleamong the rank fields, butangleis one of the five scored dimensions (score.ts:24kortform 15 %,:32long-form 20 %), persisted inscore.dimensions(R3a) and already folded into the composite the brief ranks on. No separate field is needed; §5's "saturation + first-mover (resten av #3)" is exact.- The brief's
ranking:frontmatter would misrepresent itself. The descriptor line (brief.ts:187) states the exact sort; once the overlay enterscmp, that line must name the new key or the artifact lies about its own ordering.
3. Scope — what is IN (RE-R3d)
Zero new source/test files (the two tracked slice docs aside). R3d is pure EDITs: the derived signal lives beside ageDaysBetween in brief.ts (the module
that already owns the derived-temporal ranking logic — surgical, no new module to wire into the gate), its unit
tests in brief.test.ts, its CLI flags in cli.ts/cli.test.ts, plus the wiring docs + gate.
S-signal — scripts/trends/src/brief.ts (EDIT) — the derived temporal overlay
export type TemporalTier = "first-mover" | "neutral" | "warming" | "saturated"— the four ordinal classes of the temporal axis (best → worst opportunity).neutral(the draft called this "fresh" — renamed: a tier named "fresh" collided with thefreshDaysbucketing concept AND mislabelled a 30-day-old-but-unsurfaced trend as "fresh";neutralis honestly "no exposure signal, no recency edge" — folded, all three reviewers).export interface TemporalSignal { tier: TemporalTier; firstMover: boolean; surfacings: number; rank: number }—firstMover= recent AND unsurfaced;surfacings= prior-day surfacings (surfacedCount ?? 0, the self-exposure level);rank= the descending sort integer (first-mover3 >neutral2 >warming1 >saturated0).export function temporalSignal(ageDays: number, surfacedCount: number | undefined, opts: { firstMoverDays: number; saturationAt: number }): TemporalSignal— pure (no clock/fs/env; takes the already-computedageDays+ the persistedsurfacedCount+ the injected thresholds). Logic:surfacings = surfacedCount ?? 0;const at = Math.max(1, opts.saturationAt)— a defensive clamp: a direct caller (the function is a public export the gate greps for) passingsaturationAt 0would otherwise makesurfacings >= 0always true → every non-first-mover trend "saturated". The CLI also guards< 1, but the pure function must not trust its caller (folded — plan-critic m3).firstMover = ageDays >= 0 && ageDays <= firstMoverDays && surfacings === 0— recent AND never surfaced on a prior day. TheageDays >= 0guard keeps a future-dated trend (data glitch) OUT of the "act now, you're early" headline (folded — plan-critic m5). The window maps to the SSOT "<24-72h, you would be among first" band, evaluated on the LIVE date rather than frozen at capture.- tier:
first-moveriffirstMover; elsesaturatedifsurfacings >= at; elsewarmingifsurfacings >= 1; elseneutral. (firstMoverrequiressurfacings === 0;saturated/warmingboth require `surfacings= 1` — so first-mover can collide with neither; the four branches are disjoint and total.)
rankderived from tier as above.
RankOptionsgains two knobs (brief.ts:51-54):firstMoverDays?: number(default 2) +saturationAt?: number(default 3 — the existing· sett Nxhint fires at>=2, so default 3 keepssurfacedCount 2a "warming · sett 2x" FYI and escalates>=3to "saturated"). Defaults documented likefreshDays's default 7.BriefEntrygainstemporal: TemporalSignal(brief.ts:26-36) — populated inrankForBriefright afterageDaysis computed:temporalSignal(ageDays, trend.surfacedCount, { firstMoverDays, saturationAt }).rankForBriefthreads the two new opts (brief.ts:78):const firstMoverDays = opts.firstMoverDays ?? 2; const saturationAt = opts.saturationAt ?? 3;. The bucketing (isFresh/freshDays) is unchanged — saturation does NOT move a trend between top/single/older buckets; it only re-orders within a bucket via the comparator (a soft signal, not a filter — staleness still owns the older bucket viafreshDays). Note the intended in-bucket effect this exposes (folded — plan-critic m4): insideolderMatched, aneutral(unsurfaced) trend ranks above awarming/saturatedone — "you have not been shown this stale item yet" ranks above "you have seen and skipped this stale item N times." That is the saturation signal working, not a bug; theneutral(not "fresh") name keeps it honest.cmpgains the temporal key (SB2) (brief.ts:102-107): insertb.temporal.rank - a.temporal.rankafterb.overlap - a.overlapand beforeb.effectiveDate.localeCompare(a.effectiveDate). Composite stays PRIMARY; the chain stays a total order (rankis an integer; ties fall through to the unchangedeffectiveDate → title → urltail, whose(title,url)pair is the unique id).temporalTokenreplacessurfacedToken(brief.ts:154-157): the R3b placeholder hint is promoted to the ranked signal's badge —first-mover→· 🥇 først ute;saturated→· 🔁 mettet (Nx);warming→· sett Nxonly whensurfacings >= 2(this exactly preserves the R3b ≥2 badge contract: the livesurfacedTokenfires only atc >= 2,brief.ts:156, andbrief.test.ts:408pins!md.includes("sett 1x"); the warming tier still demotes a surfaced-once trend inrank, but its badge stays suppressed at 1 — folded, all three reviewers: the draft's "preserves the hint" was inaccurate because warming covers>= 1; gating the badge at>= 2makes it true);neutral→"". Used in bothrenderTopEntry(brief.ts:162) andrenderBulletEntry(brief.ts:171), replacing thesurfacedToken(e)call. (ThescoreTokenis untouched.)briefSummarycarries the first-mover marker (brief.ts:131-141): when the top entry is a first-mover, append· 🥇 først uteinside the headline parens ((${pillar}${band} · 🥇 først ute · ${top.ageDays}d)) — the one-line surfacing the SessionStart hook shows then says "act now, you're early". The marker carries no double-quote and no newline, so the hook's^summary: *"?([^"\n]*)"?regex still captures it whole (brief.ts:128-130invariant preserved).renderBrief'sranking:descriptor names the new key (brief.ts:187): "composite desc, then pillar-overlap desc, then temporal (first-mover↑/saturated↓), then publishedAt desc (capturedAt fallback); freshDays N; excludes acted/skipped" — so the artifact self-documents its true sort.
S-cli — scripts/trends/src/cli.ts (EDIT) — two brief threshold flags
brief … [--first-mover-days N] [--saturation-at N](mirror--fresh-days,cli.ts:312-317):--first-mover-daysparses a non-negative integer (default 2; bad →usageexit 2);--saturation-atparses a positive integer (default 3;< 1→usageexit 2, sincesaturationAt 0would mark every surfaced trend saturated). Both passed intorankForBrief(store, pillars, day, { freshDays, firstMoverDays, saturationAt })(cli.ts:323).- Usage + header synopsis (
cli.ts:14,:110): add the two optional flags to thebriefline + a one-line header note that the brief applies a derived temporal overlay (first-mover↑/saturated↓) at rank time. scheduleis untouched — the nightly run uses the defaults (2/3). Baking tunable thresholds into the schedule artifact is OUT for R3d (keepsschedule.ts/run-daily.shand the R3c tests untouched — no regression surface).
Wiring (D-default — WIRE, mirrors R3a/R3b/R3c)
agents/trend-spotter.md(EDIT, prose-only, minimal): one line — the morning brief now applies a live temporal overlay at rank time (first-mover ranked up, repeatedly-surfaced/saturated ranked down), derived from the publish/capture dates + the seen-log — no new capture step; the agent's polling/capture path is unchanged. Domain-general (no vendor/sector token).references/trend-scoring-modes.md(SSOT, EDIT — one-line note only): under "Consumers", note that the morning brief applies a brief-time temporal overlay (first-mover/saturation, RE-R3d) as a within-composite-tier ranking refinement, and that this does not change the capture-time weights above. The five-dimension capture-scoring math is untouched (R3d changes no weight, no band, no formula) — the note prevents the SSOT from being misread as the whole ranking story (verifiseringsplikt; honesty).scripts/trends/README.md(EDIT): add a## Temporal overlay (RE-R3d)section between the R3c scheduler section (README.md:135-154) and## Tests(:155): the first-mover/saturation definitions, the derived-not-stored boundary (no schema bump), the--first-mover-days/--saturation-atflags + defaults, thecmpintegration (composite stays primary), and the badge set.scripts/test-runner.sh(EDIT): bumpTRENDS_TESTS_FLOOR(:713, currently 192) to thetests Nline reported after the suite runs, append+ RE-R3d: brief +N, cli +N (temporal overlay)to the inline breakdown comment. Add Section 16m ("Trends Temporal Overlay", RE-R3d) between Section 16l's closingecho ""(:1374) and the Section 18 header (:1376) (anti-erosion must stay last). Mirror 16l's shape: unconditional, deps-absent-safe (grep -qF+ a non-vacuity self-test emitting one pass/fail). 6 emitters (all on tracked source, notsx, all literals ASCII — test-runner.sh must stay ASCII-only, so the badge sentinels grep the ASCII tier/flag literals, never the emoji): (1) self-test; (2)export function temporalSignalinbrief.ts; (3) the cmp keyb.temporal.rankinbrief.ts; (4) the tier literal"first-mover"inbrief.ts; (5) the flag keyfirst-mover-daysincli.ts; (6) the flag keysaturation-atincli.ts. 6 unconditional emitters → bumpASSERT_BASELINE_FLOOR111 → exactly 117 (:1403; "live recount" is the safety net; the expected value is the pinned 111 + 6). Insert the 16m clause into the header-enumeration prose chain (:57-62) before "…the assertion-count anti-erosion floor (SC6) in Section 18", and append the R3d (→117) narration to the Section-18 floor-history comment (:1376-1402, which ends "= 111").
4. Non-goals — what is OUT (deferred)
- A stored
saturation/firstMoverfield + schema bump — OUT (SB1). The signals are derived each run; persisting an avledbar value would go stale assurfacedCountgrows and would violate theageDays-is-derived discipline.SCHEMA_VERSIONstays 4;types.tsis untouched. - Market/external saturation (how crowded the topic is across the web — how many others have posted) — OUT, needs external polling = slice (e) (the AI fan-out). R3d's saturation is self-surfacing only: a proxy for "you keep seeing this and not acting → the window is closing for you", derived from OUR seen-log. The brief is honest about which it measures (the README + the badge wording say "seen N×", not "covered N× online").
- Mutating / re-scoring the relevance composite — OUT. The R3a composite stays frozen + PRIMARY (SB2). R3d
adds a SEPARATE sort key; it never recomputes, decays, or overwrites
score.composite. The displayed composite is always the stored value. - Saturation as a filter (auto-dropping / hiding saturated trends) — OUT. Saturation demotes within a
bucket; it never removes a trend. Dropping is owned by
status(acted/skipped, R3b) and thefreshDaysstaleness window (the older bucket); R3d's overlay is a soft re-ordering signal, not a gate. - Tunable thresholds in the scheduled run — OUT, and a known limitation (not merely a later nicety —
folded — plan-critic m6 / brief-reviewer).
briefgets--first-mover-days/--saturation-atfor an interactive/tuned run; the nightlyschedulerun is the primary saturation consumer (it is what makessurfacedCountaccumulate, §1) yet is locked to the defaults (2/3) — the operator cannot retune the signal where it actually fires without re-runningschedule. Accepted for R3d to keepschedule.ts/run-daily.sh+ the R3c tests untouched (no regression surface); baking the two flags into the schedule artifact is a small, clearly-scoped follow-up. - Brief history / day-over-day diff ("what changed since yesterday" — hull 7) — slice (d).
- A new module / new test file — none. The signal lives in
brief.ts; its tests inbrief.test.ts. (No pathguard surface either — all EDITs.) - New agent / new command / new reference doc — none. R3d EDITs
brief.ts+cli.ts+ their tests + one agent (prose) + the SSOT (one note) + README + gate. Counts stay 29/19/27.BRIEF_SCHEMA_VERSIONstays 1 (the frontmatter fields are unchanged — only theranking:descriptor string + body badge tokens change; R3a/R3b added tokens without bumping it either —brief.ts:23).
5. Boundaries / invariants (must hold)
- TDD iron law (two-phase RED): failing tests land BEFORE implementation. Phase A —
temporalSignalis a new named export of the existingbrief.ts; Node16 ESM throws a missing named import at module-load, so land a non-throwing stub first (returns{tier:"fresh",firstMover:false,surfacings:0,rank:2};BriefEntry.temporalpopulated by it), THEN record value-assertion RED (the stub's constant "fresh" fails the first-mover/saturated/ ordering/token assertions). Phase B — the CLI flag tests are value-RED against the existing handler (the new flags are silently ignored today). The plan does not claim a single "everything fails before any code" run. temporalSignalis pure (no clock, no fs, no env, no AI): every input is injected (ageDaysalready computed,surfacedCountfrom the record, thresholds from the CLI edge). MirrorsageDaysBetween/renderBriefpurity. Same inputs → same output.- Determinism of the brief: given
(store, pillars, today, freshDays, firstMoverDays, saturationAt)the rendered.mdis byte-identical (R2b/R3a/R3b/R3c proved the byte-determinism of the rest; the overlay adds only a pure, injected-threshold sort key + deterministic tokens). - Composite stays PRIMARY + frozen (SB2 + R3a):
cmp's first key is unchanged (b.trend.score?.composite ?? -1); the temporal key sits after overlap. A saturated higher-composite trend still outranks a fresh lower-composite one — the overlay re-orders only WITHIN a (composite, overlap) tier. The storedscore.compositeis never read for mutation, only for sorting. - No schema / SSOT-math change:
types.tsuntouched (SCHEMA_VERSION4);brief.ts'sBRIEF_SCHEMA_VERSIONstays 1;score.ts/store.ts/item.ts/schedule.tsuntouched. The SSOT (trend-scoring-modes.md) gets a one-line consumer note only — no weight, band, or formula changes (the capture-scoring instrument is unchanged; the overlay is a separate brief-time layer). - Hook unaffected: the SessionStart surfacing reads
date+summaryonly (session-start.mjsper R3c brief §2). The summary's new· 🥇 først utemarker carries no double-quote/newline, so the extractYaml regex still captures it whole. R3d touches neither the hook nor the frontmatter field set; the hook suite must still pass untouched (regression sanity; R3d adds no hook test). - ASCII-only gate literals:
scripts/test-runner.shmust stay ASCII (a multibyte char crashes bash 3.2 underset -u). The Section-16m sentinels grep the ASCII tier/flag literals ("first-mover",first-mover-days,saturation-at,b.temporal.rank,export function temporalSignal) — never the emoji badges (which live only inbrief.tssource + rendered output, asserted by the TS tests, not by the shell gate). - Domain-general: no hard-coded user/repo path, no vendor/sector token in any edit. The tier labels +
Norwegian badge wording (
først ute,mettet,sett Nx) are domain-general UI copy (the brief's existing language); pillars/topics remain config. Section 17 de-niche stays green. - Pathguard: R3d adds no new files — every change is an EDIT of an existing file (write-allowed). (No
.mjs-under-hooks/scripts/surface, no newscripts/file.) - Counts (refs/agents/commands 27/19/29) unchanged — recounted live at land, never pinned/guessed.
6. Success criteria (testable)
- SC1 (first-mover detection) —
temporalSignal(ageDays, surfacedCount, {firstMoverDays:2, saturationAt:3}):(1, 0),(1, undefined),(2, 0),(0, 0)→{tier:"first-mover", firstMover:true, rank:3};(3, 0)(past the window) → NOT first-mover (tier:"neutral", rank:2);(1, 1)(recent but already surfaced) → NOT first-mover (tier:"warming", rank:1);(-1, 0)(future publishedAt) → NOT first-mover (tier:"neutral"— theageDays >= 0guard). Pure: same inputs → same output. - SC2 (saturation grading + clamp) — same thresholds:
(5, 3)and(5, 4)→{tier:"saturated", rank:0};(5, 2)and(5, 1)→{tier:"warming", rank:1};(5, 0)→{tier:"neutral", rank:2}.surfacedCount === saturationAtis saturated (inclusive>=). Defensive clamp:temporalSignal(5, 5, {…, saturationAt:0})classifies via the clampedat=1(NOT "every trend saturated") — the function does not trust an out-of-range threshold. - SC3 (ranking — overlay re-orders within tier, composite dominates, temporal↔date DISAGREE) — the fixture
forces the temporal key and
effectiveDateto disagree, so the test is genuinely RED in Phase A and the new key is what decides (folded — plan-critic M1:surfacedCountcorrelates with age, so a naive "first-mover vs saturated" fixture would already be ordered correctly by the existingeffectiveDate-desc key — a vacuous test). Three entries, same overlap: A =neutral(surfaced 0, older date, e.g. 5d), composite 7.0; B =warming(surfaced 2, newer date, e.g. 1d), composite 7.0; Z =saturated(surfaced 4), composite 8.5. Expected order[Z, A, B]: Z first (higher composite — PRIMARY, SB2); then A above B even though B is newer — the temporal key (neutralrank 2 >warmingrank 1) overrides theeffectiveDate-desc tiebreaker that would have put the newer B first. In Phase A (stub, constant rank) the order is[Z, B, A](effectiveDate decides A vs B) → RED.cmpremains a total order. - SC4 (render badges + the ≥2 badge boundary) —
renderBrief/the bullet path: a first-mover entry contains· 🥇 først ute; a saturated entry (surfacedCount 3, defaultsaturationAt 3) contains· 🔁 mettet (3x); a warming entry withsurfacedCount 2contains· sett 2x; a warming entry withsurfacedCount 1contains NO badge (the preserved R3b ≥2 contract —brief.test.ts:408stays green, unchanged); aneutralentry contains none of the three. The R3b· sett 3xassertion (brief.test.ts:407) is updated to· 🔁 mettet (3x)(its surfacedCount-3 trend is now saturated). Also updated (folded — brief-reviewer MEDIUM-2): the two tests pinning theranking:descriptor verbatim (brief.test.ts:325-331and the regex:410-416) gain the newthen temporal (first-mover↑/saturated↓),segment; and theeffectiveDate-isolation test (brief.test.ts: 96-102) is re-based so both entries share a temporal tier (bothneutral) — else the new key, noteffectiveDate, would silently decide it (coverage erosion — folded — brief-reviewer MEDIUM-3). - SC5 (summary first-mover marker) — when the top entry is a first-mover,
briefSummary(and the frontmattersummary:line) contains· 🥇 først uteinside the headline parens; when it is not, the marker is absent. The summary contains no"and no\n(the hook-regex invariant). - SC6 (CLI flags) —
brief --pillars ai --first-mover-days 1 --saturation-at 2 …changes the tiers vs the defaults (a 2-day-old trend is first-mover at default 2 butfreshat--first-mover-days 1;surfacedCount 2iswarmingat default 3 butsaturatedat--saturation-at 2); absent flags use defaults 2/3;--first-mover-days -1/--first-mover-days x/--saturation-at 0/--saturation-at x→usageexit 2. - SC7 (determinism) — two
briefruns with the same(store, pillars, today, freshDays, firstMoverDays, saturationAt)→ byte-identical.md. - SC8 (no schema / no score mutation) —
SCHEMA_VERSION4;BRIEF_SCHEMA_VERSION1;types.tsuntouched; abriefrun does not change any record'sscore.composite(assert the store's scores are unchanged after a brief, onlysurfacedCount/lastSurfacedAtmove — the existing R3b behaviour). - SC9 (purity) —
temporalSignalreads no clock/fs/env; a property check over a grid of(ageDays, surfacedCount)gives stable, threshold-consistent tiers (first-mover ⊆ recent∧unsurfaced; saturated ⇔surfacings >= saturationAt). - SC10 (gate + wiring + de-niche) —
bash scripts/test-runner.sh→FAIL=0: trends suite green at the bumpedTRENDS_TESTS_FLOOR; new Section 16m green (the six ASCII sentinels + non-vacuity self-test);ASSERT_BASELINE_FLOOR= 117 (111 + 6); Section 17 de-niche green; counts 29/19/27; the hook suite still green untouched (node --test hooks/scripts/__tests__/*.test.mjs).
7. Verification
Deterministic: bash scripts/test-runner.sh → FAIL=0; trends suite ≥ new floor; Section 16m self-test +
greps pass; ASSERT_BASELINE_FLOOR = 117; Section 17 de-niche green; ref/agent/command counts unchanged.
Regression sanity: node --test hooks/scripts/__tests__/*.test.mjs → still green untouched (R3d touches no
hook). The R3c suite (schedule.test.ts/run-daily.test.ts) still green untouched (schedule.ts/run-daily.sh
not edited).
Behavioural (manual):
- Seed a store with three trends sharing topics/pillars: one fresh+unsurfaced (
publishedAt~1d ago,surfacedCountabsent), one warming (surfacedCount2), one saturated (surfacedCount4) — same composite. node --import tsx src/cli.ts brief --pillars ai,gov --out /tmp/r3d-mb --store /tmp/r3d.json→ inspect the.md: the fresh+unsurfaced entry sorts first with· 🥇 først ute; the saturated one sorts last with· 🔁 mettet (4x); theranking:descriptor names the temporal key.- Re-run with
--first-mover-days 0 --saturation-at 2→ the first-mover badge disappears (0-day window) and thesurfacedCount 2entry escalates tomettet (2x). --first-mover-days x→ exit 2 (usage); fs untouched.- Confirm the seeded records'
score.compositevalues are unchanged after the brief (onlysurfacedCount/lastSurfacedAtadvance) — the overlay never mutates the relevance score.
8. Open questions for the go-gate
Two architectural decisions are CONFIRMED (operator, AskUserQuestion 2026-06-26): SB1 derived-at-brief (no schema bump); SB2 refine recency within the composite tier (composite stays primary). Residual decisions, all baked to the recommended default — confirm or redirect with "Go":
- D1 —
firstMoverDaysdefault2? YES (rec). The tight end of the SSOT "<24-72h, you would be among first" band;--first-mover-days Ntunes it. Re-open only for a different default (e.g. 3 = the full 72h "early" band). - D2 —
saturationAtdefault3? YES (rec). The existing· sett Nxhint fires at>=2, so default 3 keepssurfacedCount 2an FYI ("warming · sett 2x") and escalates>=3to "saturated".--saturation-at Ntunes it. Re-open only for a different default. - D3 — four tiers (
first-mover/fresh/warming/saturated)? YES (rec). A first-mover top, a fresh baseline, a warming FYI (preserves the R3bsett Nxhint), a saturated demotion. Drop only to collapse warming into fresh (a 3-tier model) or to add a fifth class. - D4 — temporal key sits AFTER overlap, BEFORE effectiveDate in
cmp? YES (rec, = SB2). Composite then overlap stay primary; the overlay is the coarse recency class,effectiveDatethe fine tiebreaker beneath it. Re-open only to move the key (e.g. before overlap — stronger overlay). - D5 — saturation NEVER moves a trend between top/single/older buckets (soft re-order only)? YES (rec). A
soft signal; bucketing stays
overlap+freshDays. Re-open only to let a saturated trend drop a bucket. - D6 — badges
🥇 først ute/🔁 mettet (Nx)/sett Nx(warming) / none (fresh)? YES (rec). Promotes the R3bsett Nxhint into a graded set. Re-open for different wording/emoji (the gate sentinels are ASCII, so emoji changes are test-only). - D7 — summary line carries
· 🥇 først utewhen the top is a first-mover? YES (rec). The one-line surfacing then signals "act now, you're early". Drop only to keep the summary minimal (no marker). - D8 — add a one-line overlay note to the SSOT (
trend-scoring-modes.md)? YES (rec). Honest cross-reference so the SSOT is not misread as the whole ranking story; no weight/formula change. Drop only to document the overlay solely inbrief.ts+ README + the brief'sranking:descriptor. - D9 —
scheduleuntouched (nightly run uses default thresholds)? YES (rec). Keepsschedule.ts/run-daily.sh+ the R3c tests untouched (no regression surface). Re-open only to bake--first-mover-days/--saturation-atinto the schedule artifact now. - D10 — commit split? Docs commit first, then one code commit (rec) — the overlay (signal + flags + wiring) is one coherent feature. Re-open only for a signal-then-wiring split.
9. Light-Voyage review — folded
Three Opus reviewers ran on the drafts, each verifying claims against live code. They converged on the same two
defects (the strongest signal): scope-guardian: MIXED (0 hard creep, both confirmed decisions honored,
every SC1–SC10 traces to a step; 1 MAJOR + 1 line-cite + discretionary MINORs). brief-reviewer:
PROCEED_WITH_RISKS (all seven RED-premise/correctness claims HOLD; the risk is GREEN-completeness — the plan
listed 1 of 4 breaking test assertions; 1 MAJOR + 2 MEDIUM + 2 LOW). plan-critic: APPROVE_WITH_NOTES, 78/B (the
floor arithmetic, line-cites, grep sentinels, cmp total-order, and two-phase-RED structure all verified correct;
2 MAJOR + 4 MINOR). All findings folded (per-finding resolution in plan-re-r3d.md §Plan-critic — folded).
Headlines:
- [MAJOR, folded — all three] the warming badge fired at
surfacings >= 1, but the live R3bsurfacedTokenfires only at>= 2(brief.ts:156), andbrief.test.ts:408pins!md.includes("sett 1x"). The draft's "preserves the R3b hint" was false (it broadened>=2to>=1). →temporalToken's warming badge is gated atsurfacings >= 2(R3b contract preserved exactly;:408stays green); the warming tier still demotes surfaced-once inrank. SC4 gains thesurfacedCount 1 → no badgeboundary. - [MAJOR, folded — plan-critic M1 / brief-reviewer MEDIUM-3] the ordering test was not genuinely RED + vacuous.
surfacedCountcorrelates with age, so a "first-mover vs saturated" fixture is already ordered by the existingeffectiveDate-desc key — Phase A would be GREEN and GREEN proves nothing. → SC3's fixture now forces temporal↔date disagreement (older-neutralA vs newer-warmingB at equal composite; the temporal key, not the date, must decide A>B). The coverage-erodedeffectiveDate-isolation test (:96-102) is re-based to a shared tier. - [MEDIUM, folded — brief-reviewer MEDIUM-2] the
ranking:descriptor change breaks two more pinned tests (brief.test.ts:325-331+ the regex:410-416). → the test inventory (SC4 + plan Step 1) now enumerates all four touch points, not one. - [MINOR, folded — plan-critic m3]
temporalSignalwas undefensive againstsaturationAt < 1. → aMath.max(1, saturationAt)clamp inside the pure function (the CLI guard is not enough — the function is a public, gate-grepped export). SC2 gains a clamp case. - [MINOR, folded — plan-critic m4] the "fresh" tier name was a misnomer (a 30-day-old unsurfaced trend is not
"fresh"; collides with
freshDays). → renamedneutral("no exposure signal"); the in-bucket effect (unsurfaced ranks above seen-and-skipped withinolderMatched) is documented as intended. - [MINOR, folded — plan-critic m5] a future
publishedAt(ageDays < 0) became a first-mover "act now" headline. → theageDays >= 0guard excludes it (it falls toneutral). SC1 gains the(-1, 0)case. - [MINOR, folded — plan-critic m6 / brief-reviewer] the nightly run (the primary saturation consumer) is locked to default thresholds. → reframed in §4 as a known limitation, not a "later nicety."
- [LOW, folded — all three] long-form
angleweight cite:34→:32(:34iscurrency; the substance — angle is a scored dimension in both modes — holds).