Skip to content

fix(stats): the faithfulness hard ceiling suppressed results no more expensive than the ones it returned - #452

Open
jcoludar wants to merge 1 commit into
mainfrom
fix/faithfulness-ceiling-suppresses-computable-results
Open

fix(stats): the faithfulness hard ceiling suppressed results no more expensive than the ones it returned#452
jcoludar wants to merge 1 commit into
mainfrom
fix/faithfulness-ceiling-suppresses-computable-results

Conversation

@jcoludar

Copy link
Copy Markdown
Collaborator

The ceiling was protecting nothing

DEFAULT_HARD_CEILING = 20000 refused to compute faithfulness for any dataset above 20,000 points.

The bail at faithfulness.py:218 sits 46 lines before sorted_subsample(n, sample_threshold, rng) at line 264, which caps the scored set at DEFAULT_SAMPLE_THRESHOLD = 5000 regardless of n. Every dataset above 5,000 points is therefore scored on exactly 5,000 points, and the O(n²) neighbourhood work is bounded by the subsample, not by n.

A 573,649-point dataset and a 20,000-point one cost the same to score. The ceiling only ever decided whether that identical, bounded work happened at all.

Measured with the ceiling lifted: exact (not approximate) neighbourhood computation at n = 113,015 and n = 573,606, in 17 s of metric time each, bitwise deterministic across processes, sample_size: 5000 in every run.

Two changes

  1. DEFAULT_HARD_CEILING is now None. An explicit hard_ceiling param still bails exactly as before, so the behaviour is opt-in rather than removed.

  2. The subsample now runs before the float64 upcast. The old order allocated a full-n float64 copy of the embedding and the coords, then discarded 99% of it — measured at +16.68 GB transient on a 573k × 1024 input against 0.041 GB of data actually scored. Gathering emb_raw[canonical[idx]] in one pass makes the allocation O(subsample · d).

Determinism is preserved by construction

The RNG is still seeded from id_seed(ctx.rng_seed, ids) over the full canonical id list, and canonical[idx] selects the same rows the previous emb[canonical][idx] did. ids is not read after the subsample, so the reordering is safe.

Why it matters beyond one constant

The ProtSpace manuscript had promoted this default to a stated limitation of the method, in four places, including:

lifting that ceiling requires approximate neighborhood computation

That is false, and it is refuted by the first half of its own sentence — which notes the metric already runs on a 5,000-point subsample from 5,000 to 20,000. The cost above 20,000 is identical.

git log -S DEFAULT_HARD_CEILING --all returns exactly one authoring commit, a627a18d, whose entire justification is the parenthetical "large-n sampling guard". It was never tuned or revisited.

There is a real wall, but not here

Exact-path memory scales as ~24·m², giving 0.6 GB at m = 5,000, 9.6 GB at 20,000 and ~60 GB at 50,000. That wall is on DEFAULT_SAMPLE_THRESHOLD, and the code never approaches it.

Follow-up not addressed here

hard_ceiling has no CLI flag — stats.py:317 hard-codes params, so an explicit ceiling is only settable programmatically. That is now the less common case, but worth a flag.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MvGHi1QKs9TGYEd3pNzdJH

…bounded number, and suppressed results no more expensive than the ones it returned

`DEFAULT_HARD_CEILING = 20000` refused to compute faithfulness for any dataset
above 20,000 points. The refusal was not protecting anything.

The bail at line 218 sits 46 lines BEFORE `sorted_subsample(n, sample_threshold,
rng)` at line 264, which caps the scored set at `DEFAULT_SAMPLE_THRESHOLD = 5000`
regardless of n. So every dataset above 5,000 points is scored on exactly 5,000
points, and the O(n^2) neighbourhood work is bounded by the SUBSAMPLE, not by n.
A 573,649-point dataset and a 20,000-point one cost the same to score. The
ceiling only ever decided whether that identical, bounded work happened at all.

Measured, with the ceiling lifted: exact (not approximate) neighbourhood
computation at n = 113,015 and n = 573,606, in 17 s of metric time each,
bitwise deterministic across processes. `sample_size: 5000` in every run.

Two changes:

1. `DEFAULT_HARD_CEILING` is now `None`. An explicit `hard_ceiling` param still
   bails as before, so the behaviour is opt-in rather than gone.

2. The subsample now runs BEFORE the float64 upcast instead of after it. The old
   order allocated a full-n float64 copy of the embedding and the coords and then
   discarded 99% of it -- measured at +16.68 GB transient on a 573k x 1024 input,
   against 0.041 GB of data actually scored. Gathering `emb_raw[canonical[idx]]`
   in one pass makes the allocation O(subsample * d).

Determinism is preserved by construction, not by luck: the RNG is still seeded
from `id_seed(ctx.rng_seed, ids)` over the FULL canonical id list, and
`canonical[idx]` selects the same rows the previous `emb[canonical][idx]` did.
`ids` is not read after the subsample, so the reordering is safe.

Why this matters beyond the constant: the ProtSpace manuscript had promoted this
default to a stated limitation of the method, in four places, including
"lifting that ceiling requires approximate neighborhood computation" -- which is
false, and was refuted by the first half of its own sentence (the same sentence
notes the metric already runs on a 5,000-point subsample from 5,000 to 20,000).
`git log -S DEFAULT_HARD_CEILING --all` returns exactly one authoring commit,
a627a18, whose entire justification is the parenthetical "large-n sampling
guard". It was never tuned or revisited.

There IS a real intractability wall, but it is on `DEFAULT_SAMPLE_THRESHOLD`, not
here: exact-path memory scales as ~24*m^2, giving 0.6 GB at m=5,000, 9.6 GB at
20,000 and ~60 GB at 50,000. The code never approaches it.

Known follow-up, not addressed here: `hard_ceiling` has no CLI flag
(`stats.py:317` hard-codes `params`), so an explicit ceiling is only settable
programmatically. That is now the less common case, but worth a flag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MvGHi1QKs9TGYEd3pNzdJH
@jcoludar

Copy link
Copy Markdown
Collaborator Author

Status check, 2026-08-18 — still merges clean, and the manuscript now depends on it

Re-verified today against origin/main at 082b96fc.

⚠ First, a name collision that has already caused one wrong assumption

There are two different "ceilings" in this project right now, and only one of them has moved:

what it caps where state
MAX_RENDERABLE_POINTS points the browser will draw packages/core (webgl/types.ts) moved#459 deleted the viewport cull, 1,000,000 → 2,000,000
DEFAULT_HARD_CEILING dataset size the faithfulness statistic will score apps/protspace/.../stats/metrics/faithfulness.py:41 🛑 unchanged at 20000, this PR

The manuscript's methods(nm_2026) commit 3fbc752 is titled "a merged PR moved the ceiling" — that is #459, the renderer one. The statistics ceiling this PR addresses is still 20000 on main today:

$ git grep -n DEFAULT_HARD_CEILING origin/main -- '*.py'
origin/main:apps/protspace/src/protspace/stats/metrics/faithfulness.py:41:DEFAULT_HARD_CEILING = 20000

Mergeability — clean, and checked semantically rather than just textually

faithfulness.py has moved three times on main since this branched (6c9d308c hoisting DEFAULT_SAMPLE_THRESHOLD to stats.base, f3ca79ba the spearman midrank fix, 4fc74468 skipped-metric NaN rows), so a clean merge-tree is not by itself reassuring. I inspected the merged blob:

  • DEFAULT_SAMPLE_THRESHOLD resolves to main's new protspace.stats.base import ✅
  • DEFAULT_HARD_CEILING = None intact ✅
  • the subsample-before-upcast reordering survives, and still reads the threshold through ctx.params.get("sample_threshold", DEFAULT_SAMPLE_THRESHOLD)

No rebase needed. This needs a reviewer, not a fix.

What the manuscript currently depends on

The ProtSpace NM manuscript reports faithfulness for all three published datasets plus a null floor — beta-lactamase (113,015), Swiss-Prot (573,606), Human+Drosophila (105,416). Every one of those numbers was obtained by overriding this constant (hard_ceiling = 10**9), because at the shipped default the metric returns a single {"skipped": "n_too_large"} row.

That is stated honestly in the paper — Methods says in the present tense that the ceiling is overridden — so nothing false ships either way. But it leaves a real choice:

  • Merge (and ideally release) this, and the paper describes software a reader can run to reproduce the table; or
  • Don't, and the paper keeps wording that says its headline statistics require a programmatic override of a default.

The second is publishable and is what is written today. The first is better.

One thing to look at while reviewing

The 20000 was never tuned — git log -S DEFAULT_HARD_CEILING --all returns one authoring commit (a627a18d) whose whole justification is the parenthetical "large-n sampling guard". The reason it protects nothing is that the bail sits 46 lines before a subsample that caps the scored set at 5,000 points regardless of n, so a 573,649-point dataset and a 20,000-point one cost the same to score. The ceiling only ever decided whether that identical bounded work happened at all.

There is a real wall, but it is on DEFAULT_SAMPLE_THRESHOLD (~24·m² → 0.6 GB at m=5,000, ~60 GB at m=50,000), which this PR does not touch.

Known follow-up, deliberately not in this PR

hard_ceiling still has no CLI flag — stats.py:317 hard-codes params, so an explicit ceiling is only settable programmatically. Worth a flag, but a separate change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant