Skip to content

feat(retention): bound orb_signals by folding it into per-day rollups - #9796

Merged
JSONbored merged 3 commits into
mainfrom
feat/orb-signal-rollups
Jul 29, 2026
Merged

feat(retention): bound orb_signals by folding it into per-day rollups#9796
JSONbored merged 3 commits into
mainfrom
feat/orb-signal-rollups

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Closes #9783

The problem

orb_signals had no RETENTION_POLICY rule and grew without bound — one row per PR per self-host instance, forever.

It could not simply be added to the policy, because two live surfaces read history the raw rows carry:

Surface What a plain prune would do
listFleetInstancessignalCount Silently redefines a lifetime count as "signals in the retention window", with no change of label
/v1/internal/fleet/analytics?days= and the public weekly fleet trend (#9676, 8-week lookback) Reports windows it no longer has data for — weeks collapsing to null as their rows age out

That is the same window/denominator mismatch #9676 and #9793 have been correcting, so shipping it here would have been a regression, not a cleanup.

The fix

The #9474 pattern (orb_pr_outcomesorb_outcome_rollups), which exists for exactly this shape: fold the aging rows into a durable aggregate in the same transaction that deletes them.

orb_signal_rollups keeps the whole confusion matrix per instance per day, not a scalar count. The fleet numbers are not a total — foldInstance derives decisionAccuracy from (verdict, outcome, reversal_flag, gate_reasoncode_bucket) cells, and #9775's trend buckets the same cells by day. A scalar rollup would preserve signalCount and destroy every accuracy figure past the window; per-day cells preserve both, so a historical week reconstructs to the same number it showed while the raw rows existed.

Reads union the two sources — computeFleetAnalytics, loadPublicFleetAccuracyTrend, listFleetInstances — with the registered-instance trust gate applied at read time on both halves.

Decisions worth reviewing

  • Unregistered instances still fold. Discarding their history at prune time would make a later registration silently lossy, so the trust gate moved to the read side rather than the write side. Tests pin that their folded history still never moves a published number.
  • gate_verdict / gate_reasoncode_bucket are NOT NULL DEFAULT '', unlike the nullable source columns. A PRIMARY KEY over nullable columns does not work: SQLite does not enforce NOT NULL on PK columns of a rowid table and compares every NULL as distinct, so two folds of the same day would insert duplicate cells instead of accumulating. '' is safe for both readers — foldInstance only tests === "merge" / === "close" / === "policy_action", which '' fails exactly as NULL did.
  • Per-PR identity is genuinely dropped (repo_hash/pr_hash). Those are per-instance HMACs that cannot be reversed or joined across instances; their only role is the ingest dedup key, which matters while a row can still be re-exported and not after it has aged out.
  • substr(?1, 1, 10) on the trend's rollup half. That query bounds on a full ISO instant while day is YYYY-MM-DD, and a string prefix sorts before the string it prefixes — a bare day >= ?1 drops the window's first day. computeFleetAnalytics does not need this, because its cutoff is already date-only. Both are commented, and the trend has a regression test that fails without the fix (verified by reverting it).

Coverage

src/db/retention.ts, src/orb/analytics.ts, src/services/public-fleet-accuracy-trend.ts all at 100% lines / 100% branches. The fleet-admin.ts change is a SQL edit inside an already-covered function.

Reaching 100% branches required the multi-slice arms, so the fold gets the same three guards the orb_pr_outcomes fold has: bounded slices, a run of tied timestamps still making progress (an exclusive boundary selects zero rows and spins forever), and maxPerTable capping one run — the last of which also asserts the unioned read is exact at the intermediate point, with half the cohort folded and half still raw.

Validation

db:migrations:check (contiguous 0001..0202) · db:migrations:immutable:check · db:schema-drift:check · validate:no-hand-written-js · dead-source-files:check · import-specifiers:check · tsc --noEmit — all clean. 138 tests pass across the three touched suites.

No visual output touched.

@loopover-orb

loopover-orb Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Warning

⏸️ LoopOver review result - manual review recommended

Review updated: 2026-07-29 11:24:29 UTC

9 files · 1 AI reviewer · 1 blocker · CI green · clean

⏸️ Suggested Action - Manual Review

Review summary
This PR bounds the previously-unbounded orb_signals table by folding aging rows into a new per-instance/per-day confusion-matrix rollup table (orb_signal_rollups) in the same atomic batch that deletes them, following the established #9474 pattern. The read paths (computeFleetAnalytics, loadPublicFleetAccuracyTrend, listFleetInstances) are updated to UNION live rows with folded rollups so decisionAccuracy and lifetime signalCount survive the prune, and the migration is D1-remote-safe (no CREATE TEMP/ATTACH/PRAGMA/transaction-control). However, the cycle-time query in computeFleetAnalytics was left unchanged and still reads only from the raw orb_signals table, so cycleP50Ms/cycleP95Ms will silently degrade for any window beyond the new 90-day retention cutoff — precisely the scenario (`?days=` up to 365) the PR's own description calls out as the failure mode it exists to prevent for the confusion-matrix data, but doesn't address for cycle time.

Blockers

  • computeFleetAnalytics' cycle-time query (`cy`) still reads only from orb_signals and was not updated to union with orb_signal_rollups (which has no time_to_close_ms column), so cycleP50Ms/cycleP95Ms will silently under-report as rows older than 90 days are folded and deleted, for any windowDays request beyond 90 (windowDays is clamped to a max of 365 in this same function).
Nits — 5 non-blocking
  • src/db/retention.ts:31 and src/orb/fleet-admin.ts:24 both add a bare literal `90` for the retention window, matching the codebase's existing convention of inline day-count literals elsewhere in RETENTION_POLICY, but a shared named constant would make the coupling between the retention cutoff and the fold-union cutoff more explicit.
  • No test in the visible diff exercises cycleP50Ms/cycleP95Ms across a prune boundary, which would have surfaced the cycle-time gap noted above.
  • Either fold time_to_close_ms into orb_signal_rollups (e.g. as a running sum/count for computing a rollup-side average, accepting a precision loss for percentiles) or explicitly cap/document that cycle-time percentiles are only accurate within the 90-day retention window regardless of requested windowDays.
  • Add a regression test analogous to the existing 'sums raw and folded halves' test in orb-analytics.test.ts but for cycleP50Ms/cycleP95Ms to make the current behavior (or the fix) explicit.
  • PR author also opened the linked issue — Link an issue that was opened by a different contributor, or provide a rationale for why this self-authored issue represents genuine discovery work.

Concerns raised — review before merging

  • computeFleetAnalytics' cycle-time query (`cy`) still reads only from orb_signals and was not updated to union with orb_signal_rollups (which has no time_to_close_ms column), so cycleP50Ms/cycleP95Ms will silently under-report as rows older than 90 days are folded and deleted, for any windowDays request beyond 90 (windowDays is clamped to a max of 365 in this same function).
📋 Copy for AI agents — paste into your coding agent
Fix the following blocker(s) from this PR review:

1. computeFleetAnalytics' cycle-time query \(\`cy\`\) still reads only from orb\_signals and was not updated to union with orb\_signal\_rollups \(which has no time\_to\_close\_ms column\), so cycleP50Ms/cycleP95Ms will silently under-report as rows older than 90 days are folded and deleted, for any windowDays request beyond 90 \(windowDays is clamped to a max of 365 in this same function\).

Decision drivers

  • ❌ Code review — 1 blocker (1 reviewer)
  • ❌ Gate result — Blocking (Repo-configured hard blocker found.)
Context & advisory signals — never blocks the verdict
Signal Result Evidence
Linked issue ✅ Linked #9783
Related work ✅ No active overlap found No same-issue or scoped active PR overlap found.
Change scope ✅ 20/20 Low review scope from cached public metadata (1 linked issue).
Validation posture ✅ 25/25 PR body includes validation/test evidence.
Contributor workload ✅ 10/10 Author activity: 14 registered-repo PR(s), 13 merged, 357 issue(s).
Contributor context ✅ Confirmed Gittensor contributor JSONbored; Gittensor profile; 14 PR(s), 357 issue(s).
Improvement ✅ Minor risk: clean · value: minor
Linked issue satisfaction

Addressed
The PR adds the missing orb_signals retention rule while implementing Option B (folding aging rows into daily per-cell orb_signal_rollups before deletion), and updates all three readers (computeFleetAnalytics, loadPublicFleetAccuracyTrend, listFleetInstances) to union rollups with live rows, with tests pinning that fleet accuracy and lifetime signalCount are unchanged across a prune and that unreg

Review context
  • Author: JSONbored
  • Role context: owner (maintainer lane)
  • Public audience mode: oss maintainer
  • Lane context: Repository is registered but has no active allocation in the current snapshot.
  • Public profile languages: Python, TypeScript, Ruby, Go, MDX, Shell, Solidity, JavaScript
  • Official Gittensor activity: 14 PR(s), 357 issue(s).
  • PR-specific overlap: none found.
Contributor next steps
  • Start here: Treat this as maintainer-lane context rather than normal contributor-lane activity.
  • Then work through the remaining 1 step in the Signals table above.
Signal definitions
  • Related work = same linked issue, overlapping active PRs, or title/path similarity.
  • Change scope = cached public metadata such as size labels, draft state, and review-burden hints.
  • Validation posture = whether the PR provides enough public validation/test evidence for maintainer review.
  • Contributor workload = public contributor activity and cleanup pressure, not a repo-wide quality failure.
  • Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.
🧪 Chat with LoopOver

Ask LoopOver a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.

  • @loopover ask <question> answers contribution-quality Q&A with source citations and freshness.
  • @loopover chat <question> answers in natural prose from cached decision-pack facts via local inference (maintainer/collaborator; read-only).
  • A plain-language @loopover mention with a real question is routed to the closest matching read-only command automatically — no exact syntax required.

Full command reference: https://loopover.ai/docs/loopover-commands

🧪 Experimental — new and may change.

Decision record
  • action: hold · clause: ai_consensus_defect
  • config: 55e6f28064e3b0671b681dedc9c22923dab0d9f170162232baadce64377ad9af · pack: oss-anti-slop · ci: passed
  • model: claude-code · prompt: f4983bd51d4d7c7c5c3c2ed23623f726e794d9a715122c6d399332f0c21fe16e · confidence: 0.75
  • record: 1ccbe82940d20def8d1d58792c8d010bf385862f604d10e931422f1aba7a0c1f (schema v5, head f31fdeb)

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed


💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →.

Checked by LoopOver, a quiet PR intelligence layer for OSS maintainers.

  • Re-run LoopOver review

@JSONbored JSONbored self-assigned this Jul 29, 2026
@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

orb_signals had no retention rule and grew without bound -- one row per PR
per self-host instance, forever. It could not simply be added to
RETENTION_POLICY, because two live surfaces read history the raw rows carry:

  * listFleetInstances reports a LIFETIME signalCount per instance, which a
    plain prune would silently redefine as "signals in the retention window"
    with no change of label;
  * /v1/internal/fleet/analytics takes an arbitrary ?days=, and the public
    weekly fleet trend (#9676) looks back 8 weeks -- both would report windows
    they no longer had data for.

So this is the #9474 pattern: fold the aging rows into a durable aggregate in
the SAME transaction that deletes them. orb_signal_rollups keeps the whole
confusion matrix per instance per day rather than a scalar count, because the
fleet numbers are not a total: foldInstance derives decisionAccuracy from
(verdict, outcome, reversal_flag, gate_reasoncode_bucket) cells, so a scalar
rollup would preserve signalCount and destroy every accuracy figure past the
window. Per-day cells preserve both -- a historical week reconstructs to the
same number it showed while the raw rows existed.

Reads union the two sources: computeFleetAnalytics, loadPublicFleetAccuracyTrend
and listFleetInstances each add the rollups back in, with the registered-instance
trust gate applied at READ time on both halves. The fold deliberately keeps
unregistered instances' history, since an instance can be registered after its
signals arrive and discarding it at prune time would make that later
registration silently lossy.

What is genuinely dropped is per-PR identity (repo_hash/pr_hash) -- per-instance
HMACs that cannot be reversed or joined across instances, whose only role is the
ingest dedup key, which matters while a row can still be re-exported and not
after it has aged out.

Closes #9783
@JSONbored
JSONbored force-pushed the feat/orb-signal-rollups branch from c02ad6a to a2eb304 Compare July 29, 2026 09:21
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.46%. Comparing base (fa4c6d4) to head (f31fdeb).
⚠️ Report is 3 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #9796   +/-   ##
=======================================
  Coverage   90.46%   90.46%           
=======================================
  Files         918      918           
  Lines      113940   113957   +17     
  Branches    27053    27060    +7     
=======================================
+ Hits       103079   103096   +17     
  Misses       9533     9533           
  Partials     1328     1328           
Flag Coverage Δ
backend 95.57% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/db/retention.ts 100.00% <100.00%> (ø)
src/orb/analytics.ts 100.00% <100.00%> (ø)
src/orb/fleet-admin.ts 100.00% <ø> (ø)
src/services/public-fleet-accuracy-trend.ts 100.00% <ø> (ø)

@loopover-orb loopover-orb Bot added the manual-review Gittensor contributor context label Jul 29, 2026
…upport

Review catch on the orb_signals rollup: the confusion matrix folds into per-day
cells and sums back exactly, but cycleP50Ms/cycleP95Ms are percentiles over
individual time_to_close_ms values. Percentiles are not summable, so no per-day
count can reconstruct them -- and orb_signal_rollups deliberately stores counts
rather than a sample of durations, since an unbounded sample is the growth
problem retention exists to solve.

So computeFleetAnalytics' cycle query, left reading raw orb_signals, would
compute percentiles from only the rows that survived the prune and report them
as describing the whole requested window. windowDays is clamped to 365 while
orb_signals now prunes at 90, so ?days= beyond 90 silently narrows -- the exact
failure this change prevents for the matrix.

The percentiles now go null past the retention horizon, with cycleTimeObservable
saying why: the posture gamingDetectionEligible and fleetFramingEligible already
established, where a surface labels a gap instead of being handed a number that
quietly means something narrower than its window. The horizon is read from
RETENTION_POLICY rather than restated, so the window this module trusts cannot
drift from the one the prune enforces.

The default 90-day window -- the public headline -- is unaffected: it equals the
retention window, so cycle time stays fully observable there. Existing consumers
(federated-bundle/federated-import) already type these as number | null.
@JSONbored

Copy link
Copy Markdown
Owner Author

Good catch — that was a real gap, now fixed in efb886a.

Why the fold genuinely can't carry it. The confusion matrix survives because per-day cells sum. cycleP50Ms/cycleP95Ms are percentiles over individual time_to_close_ms values, and percentiles aren't summable — no per-day count reconstructs them. Storing a sample of durations instead would reintroduce exactly the unbounded growth this table exists to fix, so the rollup deliberately has no time_to_close_ms column.

What it does instead. Past the retention horizon the percentiles go null, with a new cycleTimeObservable flag saying why. That's the posture gamingDetectionEligible and fleetFramingEligible already set here: a surface labels the gap rather than being handed a number that quietly means something narrower than its window. Returning a real-looking p50 computed from 90 days of survivors while the caller asked for 365 is the failure you identified; null plus a reason is the honest answer.

The horizon comes from retentionCutoffIsoForTable("orb_signals") rather than a restated constant, so the window this module trusts can't drift from the one the prune enforces.

The default window is unaffected. windowDays defaults to 90 and retention is 90, so they're equal and cycle time stays fully observable — the public headline doesn't change. Only ?days= beyond 90 nulls, which is precisely the range that had no honest answer. Existing consumers (federated-bundle.ts, federated-import.ts) already type both as number | null and validate with nullableNumeric, so nothing downstream breaks.

Four tests added, including your exact scenario (?days=365 against a 90-day-pruned table) and one asserting decisionAccuracy still folds correctly over that same long window — the distinction that makes nulling cycle time acceptable rather than a general capitulation on long windows.

src/orb/analytics.ts and src/db/retention.ts remain at 100% lines / 100% branches.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 29, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
loopover-ui f31fdeb Commit Preview URL

Branch Preview URL
Jul 29 2026, 10:55 AM

@JSONbored
JSONbored merged commit 9ec808c into main Jul 29, 2026
9 checks passed
@JSONbored
JSONbored deleted the feat/orb-signal-rollups branch July 29, 2026 11:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

manual-review Gittensor contributor context

Projects

None yet

Development

Successfully merging this pull request may close these issues.

orb_signals grows unbounded, and adding retention would silently corrupt two existing surfaces

1 participant