Skip to content

fix(engine): normalize classifyPortfolioConvergence's numeric inputs#6225

Merged
loopover-orb[bot] merged 2 commits into
JSONbored:mainfrom
luciferlive112116:fix/portfolio-convergence-normalize
Jul 15, 2026
Merged

fix(engine): normalize classifyPortfolioConvergence's numeric inputs#6225
loopover-orb[bot] merged 2 commits into
JSONbored:mainfrom
luciferlive112116:fix/portfolio-convergence-normalize

Conversation

@luciferlive112116

Copy link
Copy Markdown
Contributor

Summary

Closes #6173

classifyPortfolioConvergence is composed into the same fail-closed ladder as governor/budget-cap.ts and governor/rate-limit.ts (all three feed evaluateGovernorChokepoint), but read its counts and thresholds raw while both siblings clamp theirs. A NaN or negative value failed every >= threshold check and the trailing > 0 check, so the classifier quietly returned "converging" — the allow-equivalent verdict — where its siblings fail toward deny. It also printed NaN straight into the reasons a maintainer reads.

Fix: normalize every count and threshold with the same rule the siblings apply.

The fail-closed direction comes from clamping both operands, which is exactly how budget-cap behaves — its own test spells this out: "budget: used 0 (NaN→0), limit 0 (Infinity→0) ⇒ 0 >= 0 ⇒ exceeded." Here the same clamp makes consecutiveFailures >= maxConsecutiveFailures (0 >= 0) read non_convergent rather than converging.

It also closes a quieter hole: NaN <= 0 is false, so an unnormalized non-finite attempts count fell past the no-attempts guard and got classified on the streak counters of an item that had never been attempted at all.

Scoped to malformed input only — the legitimate-input threshold logic is untouched, with tests pinning that.

On "reuse the helper, don't reimplement it"

There is no shared export to import: finiteNonNegativeInt is module-private in every one of the 18 pure engine modules that use it, including both named siblings and this file's own directory neighbour portfolio/queue.ts. non-convergence.ts also deliberately has no imports at all. So this reuses the rule — byte-identical to rate-limit.ts's — as a private copy matching that established convention, rather than coupling portfolio/ to governor/ for a one-line clamp or refactoring 18 modules (well outside this issue's scope).

Scope

Validation

  • git diff --check clean.
  • npm run build:miner — exit 0.
  • npm run typecheck — exit 0.
  • npm run test --workspace @loopover/engine (a test:ci step) — 563 pass, 0 fail.
  • Root vitest spec — 10 passed, coverage on packages/loopover-engine/src/portfolio/non-convergence.ts: 100% statements (21/21), 100% branch (17/17), 100% functions (2/2), 100% lines (21/21).
  • Patch coverage verified line-by-line against coverage/lcov.info: every changed executable line is hit.
  • Proved the tests catch the bug: reverted only the source fix and confirmed the new tests fail (a malformed threshold fails CLOSED, a non-finite attempts count reads converging, a fractional count is floored), then pass again with it restored.
  • Rebased onto current main.

Why the tests live in two suites

packages/loopover-engine/test/portfolio-non-convergence.test.ts is the location the issue names and runs in test:ci — but it executes under node:test against dist/, so it produces no coverage signal for src/. The root vitest spec exercises the same normalization through the source path, which is what Codecov measures. Both are needed: the first satisfies the issue's named file and the package's own gate; the second satisfies the 99% patch bar.

If any required check was skipped, explain why:

  • Full test:ci not run end-to-end locally (Linux-only steps on Windows). test/unit/miner-portfolio-queue.test.ts has 2 unrelated failures in this environment (file-permission/XDG-path assertions) — confirmed pre-existing on a clean checkout of main, and untouched by this diff.

Safety

  • No secrets, wallets, hotkeys, trust scores, rewards, private rankings, or private maintainer evidence.
  • Security-relevant and strictly safer: this is a fail-open → fail-closed correction on a signal feeding a fail-closed gate. Malformed data can no longer produce the allow-equivalent verdict, and the change can only move a malformed-input verdict toward deny — never the reverse.
  • No auth/cookie/CORS/GitHub App/session change.
  • Pure and side-effect-free: no IO, no storage, no clock read — unchanged in that respect.
  • Legitimate-input behavior is byte-identical (a finite, non-negative integer normalizes to itself), pinned by tests.
  • No API/OpenAPI/MCP change; no schema change; no generated artifact affected.
  • No UI changes; no changelog edit.

classifyPortfolioConvergence feeds the same fail-closed ladder as
governor/budget-cap.ts and governor/rate-limit.ts (all three compose into
evaluateGovernorChokepoint), but read its counts and thresholds raw while
both siblings clamp theirs. A NaN or negative value failed every >=
threshold check and the trailing > 0 check, so the classifier quietly
returned "converging" -- the allow-equivalent verdict -- where its
siblings fail toward deny. It also printed NaN straight into the reasons
a maintainer reads.

Normalize every count and threshold with the same rule the siblings
apply. The fail-closed direction comes from clamping BOTH operands, which
is exactly how budget-cap behaves: a non-finite ceiling becomes 0, so
used >= limit (0 >= 0) reads exceeded. Here the same clamp makes
consecutiveFailures >= maxConsecutiveFailures (0 >= 0) read
non_convergent rather than converging.

It also closes a quieter hole: NaN <= 0 is false, so an unnormalized
non-finite attempts count fell PAST the no-attempts guard and got
classified on the streak counters of an item that had never been
attempted at all.

The helper is module-private, matching this file's siblings rather than
coupling to them: budget-cap.ts, rate-limit.ts, portfolio/queue.ts and 15
other pure engine modules each carry their own copy of this one-line
clamp, and non-convergence.ts deliberately has no imports. Reusing the
rule, not reimplementing a different one.

Scoped to malformed input only -- the legitimate-input threshold logic is
untouched, with tests pinning that.

Tests live in both suites on purpose: the engine package's own
node:test file is the issue's named location and runs in test:ci, but it
executes against dist/ and so yields no coverage signal for src/. The
root vitest spec exercises the same normalization through the source
path, which is what Codecov measures -- 100% statements and branches
(21/21, 17/17). Verified to fail against the pre-fix classifier.

Closes JSONbored#6173
@superagent-security

Copy link
Copy Markdown
Contributor

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

@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.35%. Comparing base (2af2da3) to head (a09d548).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #6225   +/-   ##
=======================================
  Coverage   95.35%   95.35%           
=======================================
  Files         598      598           
  Lines       47102    47108    +6     
  Branches    14988    14989    +1     
=======================================
+ Hits        44913    44921    +8     
+ Misses       1464     1463    -1     
+ Partials      725      724    -1     
Flag Coverage Δ
shard-1 44.03% <58.33%> (+<0.01%) ⬆️
shard-2 36.68% <41.66%> (+0.02%) ⬆️
shard-3 32.43% <0.00%> (+0.20%) ⬆️
shard-4 34.28% <83.33%> (-0.13%) ⬇️
shard-5 31.80% <58.33%> (+0.19%) ⬆️
shard-6 44.89% <100.00%> (-0.29%) ⬇️

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

Files with missing lines Coverage Δ
...s/loopover-engine/src/portfolio/non-convergence.ts 100.00% <100.00%> (+12.50%) ⬆️

@loopover-orb loopover-orb Bot added the gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier. label Jul 15, 2026
@loopover-orb

loopover-orb Bot commented Jul 15, 2026

Copy link
Copy Markdown

Tip

✅ LoopOver review result - approve/merge recommended

Review updated: 2026-07-15 23:39:05 UTC

3 files · 1 AI reviewer · no blockers · readiness 77/100 · CI green · clean

✅ Suggested Action - Approve/Merge

  • safe to merge

Review summary
This normalizes classifyPortfolioConvergence's numeric inputs (attempts, consecutiveFailures, reenqueues, and both thresholds) to non-negative integers via a module-private finiteNonNegativeInt, matching the fail-closed clamp convention of governor/rate-limit.ts and governor/budget-cap.ts. The logic is correct: clamping both operands of each >= check means a malformed threshold now reads non_convergent (deny-equivalent) instead of quietly falling through to converging (allow-equivalent), and the NaN<=0 guard bug that let malformed attempts skip the no-attempts check is fixed. Tests are thorough, covering both the malformed-input paths and pinning legitimate-input behavior unchanged, added in both the root vitest suite and the engine package's own node:test suite.

Nits — 4 non-blocking
  • The duplicated finiteNonNegativeInt across 18+ modules (acknowledged in the PR description) is a real maintenance liability even if out of scope here; consider filing a follow-up issue to extract a shared util.
  • test/unit/portfolio-non-convergence-normalization.test.ts and packages/loopover-engine/test/portfolio-non-convergence.test.ts are near-duplicate test suites (justified by dist/ vs src/ coverage), but this doubles future maintenance if the classifier's behavior changes again.
  • Consider adding a one-line JSDoc reference from finiteNonNegativeInt back to the sibling implementations (rate-limit.ts, budget-cap.ts) so future readers can quickly diff for drift if the shared rule ever changes.
  • If the 18-module duplication of finiteNonNegativeInt becomes a recurring source of drift bugs, a shared internal util (even if only for pure/engine modules) would be worth revisiting outside this issue's scope.

Decision drivers

  • ✅ Code review — No blockers (1 reviewer)
  • ✅ Gate result — Passing (No configured blocker found.)
Context & advisory signals — never blocks the verdict
Signal Result Evidence
Linked issue ✅ Linked #6173
Related work ⚠️ 2 scoped overlaps Top overlaps are listed below; lower-confidence bulk is hidden.
Change scope ❌ 8/20 High 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: 131 registered-repo PR(s), 70 merged, 32 issue(s).
Contributor context ✅ Confirmed Gittensor contributor luciferlive112116; Gittensor profile; 131 PR(s), 32 issue(s).
Improvement ✅ Minor risk: clean · value: minor · LLM: moderate
Linked issue satisfaction

Addressed
The diff adds a module-private finiteNonNegativeInt normalization applied to all counts and thresholds before the threshold checks, matching the fail-closed clamp convention described in the issue, and includes regression tests for NaN/negative consecutiveFailures/reenqueues plus malformed-threshold cases proving deny-equivalent (non_convergent) results instead of the prior false 'converging' verd

Review context
Contributor next steps
  • Start here: Review top overlaps.
  • Then work through the remaining 3 steps 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 &lt;question&gt; answers contribution-quality Q&A with source citations and freshness.
  • @loopover chat &lt;question&gt; 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://gittensory.aethereal.dev/docs/loopover-commands

🧪 Experimental — new and may change.

🟩 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

@loopover-orb loopover-orb Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LoopOver approves — the gate is satisfied and CI is green.

@loopover-orb loopover-orb Bot merged commit 53f3726 into JSONbored:main Jul 15, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gittensor:bug Gittensor-scored bug fix — scores a 0.05x multiplier.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(engine): classifyPortfolioConvergence doesn't normalize numeric input, wrong-direction failure mode vs its sibling chokepoint calculators

1 participant