Skip to content

docs(common): the state budget is max_len - head_len - 1, not max_len - head_max_len - #696

Merged
NandhaKishorM merged 2 commits into
NandhaKishorM:mainfrom
Parswanadh:pr/p3-state-budget
Sep 29, 2026
Merged

NandhaKishorM merged 2 commits into
NandhaKishorM:mainfrom
Parswanadh:pr/p3-state-budget

Conversation

@Parswanadh

Copy link
Copy Markdown
Contributor

What

head_max_len is a cap, not the head length. build_sequence fills the option prompt budget only as far as the question and its option descriptions need, then sizes the state from the head it actually built:

room = max(0, max_len - len(ids) - 1)     # len(ids) is the head that was built

The README (Honest limits → High-cardinality choice questions and token budgets) describes the remaining state budget as max_len - head_max_len, and gives ~320 / ~768 tokens for state. That is a lower bound, and at the shipped defaults it understates the room substantially.

Measured

Each checkpoint's own tokenizer, the shipped 4-option department question:

checkpoint max_len head_max_len measured head measured state room README
laya (english) 512 192 48 463 320
laya-multilingual 1024 256 45 978 768

So the documented figure is low by 143 tokens (45 %) on laya and 210 (27 %) on laya-multilingual. head_len <= head_max_len + 3, so the cap-based figure is never an overestimate — the failure is one-directional and quiet.

User impact. A caller who sizes a document to the documented budget truncates earlier than the library would and drops state the model would otherwise have read. It is the same class of defect as #174 — a limit derived from a cap rather than from the budget actually in force — which is why it seemed worth a PR rather than a note.

Changes

  • README.md — the bullet now states that head_max_len is a cap, gives the measured room per checkpoint, notes that the room is max_len - head_len - 1 for the specific request, and gives the head_len <= head_max_len + 3 bound.
  • tests/test_state_budget.py (new) — pins the relationship so the renderer and the README cannot drift apart again:
    • head_len <= head_max_len + 3
    • a state longer than the room is cut to exactly max_len - head_len - 1
    • a state of exactly max_len - head_max_len tokens survives untruncated — the assertion the old wording contradicts
    • the two measured rooms above, when the checkpoints are present (skipped otherwise)

No code change: build_sequence is already correct. Only the description was wrong.

Verification

$ python tests/test_state_budget.py
11 passed, 0 failed

$ ruff check laya/ --select=E9,F63,F7,F82,F401,F811 --line-length=120
All checks passed!

$ python -m compileall -q laya/ tests/
OK

No public API change, so tests/test_hooks_api.py is untouched.

(Note: ruff check tests/ reports 12 pre-existing findings on main, all in tests/test_truncation_direction.py — unrelated to this PR and left alone.)

Scope

Docs plus one test. Branched from current main (9d95567).

… - head_max_len

head_max_len is a cap, not the head length. build_sequence fills the option prompt
budget only as far as the question and its option descriptions need, then sizes the
state from the head it actually built (room = max_len - len(ids) - 1). The README
described the remaining state budget as max_len - head_max_len, which is a lower
bound rather than the real figure.

Measured with each checkpoint's own tokenizer and the shipped 4-option question:

  laya (english)     max_len=512  head_max_len=192  head=48  room=463 (README: 320)
  laya-multilingual  max_len=1024 head_max_len=256  head=45  room=978 (README: 768)

so the documented figure understates the room by 143 tokens (45%) on english and 210
(27%) on multilingual. A caller sizing a state to it truncates earlier than the library
would and drops state the model would otherwise have read.

Adds tests/test_state_budget.py pinning the relationship: head_len <= head_max_len + 3,
the kept state length is exactly max_len - head_len - 1, and a state of the documented
size survives untruncated (the assertion the old wording contradicts).

@Bruce-Yii Bruce-Yii left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The core finding is right, and I reproduced it: laya/common.py sizes the state from the head it actually assembled, so max_len - head_max_len was understating the room. At the shipped English defaults the head comes out at 33 tokens, so the room is 478 rather than the documented 320, and a state sized to the documented figure survives with its tail marker intact. Worth fixing.

The problem is the sentence added to justify it. "The cap-based figure is never an overestimate, since head_len <= head_max_len + 3" is false, and it fails in the direction that matters. per = max(4, (head_max_len - 16) // k) at laya/common.py:176 floors at 4 tokens per option, and head_ids is floored at 8 at laya/common.py:180, so once 4*k passes the cap the head runs over it:

head_max_len=192  (so the bound claims head_len <= 195)
k=46   per=4    head_len=200    bound holds=False
k=47   per=4    head_len=204    bound holds=False
k=77   per=4    head_len=324    bound holds=False

The marker guard still passes at those counts — every marker survives — so these are accepted calls, not rejected ones. A state sized to the cap-based figure then gets truncated rather than being "safe but wasteful". The +3 looks like it came from the 4-option fixture, where the head never fills the cap.

This is not a corner case. k=77 is the cardinality the neighbouring bullet is about, and at the shipped defaults the cap-based figure overstates the real room by 125 tokens:

k=77  max_len=512    real_room=187    cap_based=312
k=100 max_len=512    real_room=95     cap_based=312
k=253 max_len=1024   real_room=0      cap_based=824

MAX_CHOICE_OPTIONS = 100 in laya/serve.py:71 puts the overstating regime within reach over HTTP. So the fix trades one wrong figure for another, and the new one sends callers into a configuration that silently drops document text. Dropping the bound sentence — or replacing it with the real relationship, that the room is max_len - head_len - 1 for the question at hand and head_len grows with option count — leaves the correct part of this PR standing. The 463/978 figures are the good part and are worth landing.

Two things on the test file, both small.

It never opens README.md, so the docstring's claim that the suite stops "the renderer and the README" desynchronising doesn't hold. I put main's README and main's laya/ under the new test and got 7 passed under the script and 4 passed under pytest, exit 0 both ways, with the stale wording back. It does catch renderer regressions — I confirmed by mutating the room = ... line, where cap-sizing, an off-by-one and a halved room are all caught — which is worth keeping. One gap inside that: dropping the -1 entirely is not caught.

test_shipped_checkpoints_match_the_documented_room returns rather than skipping, so on a machine without the checkpoints it passes vacuously, and it's the only check on the 463/978 figures. pytest -rs and -W error still report it as passed — no runner flag turns that return into a failure. A pytest.skip would make it visible. The file also isn't wired into ci.yml or release.yml yet, so nothing runs it in CI.

On the second occurrence: README.md:638 and the -8 figures in laya/agent.py, laya/onnx_agent.py and laya/router.py describe predict_long's window budget, which is a different quantity and does match the code. Those are #689's subject and they compose cleanly — git merge-tree on this PR against #689 exits 0, and the two hunks touch different sentences about 480 lines apart. docs/hooks/patterns.md:291 is the one that nominally claims the state room; it's conservative in the regime it documents (off by a constant 12 tokens) rather than unsafe, so it isn't a blocker either.

…real

Review (Bruce-Yii) is right on both counts. Verified the counterexample directly:
the bound head_len <= head_max_len + 3 is FALSE from k=47 options at head_max_len=192
(k=46 -> 195, k=47 -> 199, k=77 -> 319, with the cap at 195). per = max(4, ...) floors
at 4 tokens per option and head_ids floors at 8, so past roughly head_max_len/4 options
the head grows beyond the cap and the marker guard still passes.

Consequence, measured on both checkpoints with their own tokenizers:

  english,       max_len=512:  k=4 room=463 cap=320 (understates)
                               k=77  room=192 cap=320 (overstates by 128)
                               k=100 room=100 cap=320 (overstates by 220)
  multilingual,  max_len=1024: k=4 room=978 cap=768 (understates)
                               k=100 room=612 cap=768 (overstates by 156)

so max_len - head_max_len is wrong in BOTH directions and, for a high-cardinality
question, wrong in the unsafe one: a state sized to it is truncated rather than
merely conservative. Replaces the bound sentence with the real relationship and the
measured table. The 463/978 figures are unchanged -- they were the correct part.

Test file, per the same review:
- test_readme_quotes_the_measured_figures now parses README.md and compares its quoted
  head/room figures against a live measurement. Verified it FAILS with main's README
  under this test, which it did not before. The docstring claim now holds.
- skips are visible in both runners (pytest.skip under pytest, printed SKIP as a
  script) instead of a vacuous return; confirmed with LAYA_LAB_MODELS=/nonexistent.
- test_dropping_the_minus_one_is_caught closes the gap where dropping the -1 was not
  caught: one token over the room must lose its tail.
- test_cap_based_figure_overstates_a_high_cardinality_question pins the new claim.
- wired into ci.yml and release.yml alongside the other plain suites.

8 passed as a script, 5 passed under pytest, ruff and compileall clean.
@Parswanadh

Copy link
Copy Markdown
Contributor Author

Thank you — you're right on both counts, and I reproduced the counterexample before changing anything.

The bound was false, and in the direction that matters. Measured directly with each checkpoint's own tokenizer (head_max_len = 192):

k=46   head=195   bound(hml+3)=195   holds
k=47   head=199   bound(hml+3)=195   FALSE
k=77   head=319   bound(hml+3)=195   FALSE

per = max(4, ...) flooring at 4 and the head_ids floor of 8 are exactly the mechanism. Your reading of where the +3 came from is also right — it was the 4-option fixture, where the head never approaches the cap.

The overstating regime, on both checkpoints:

checkpoint k head real room cap-based
laya, max_len=512 4 48 463 320 (understates)
laya, max_len=512 77 319 192 320
laya, max_len=512 100 411 100 320
laya-multilingual, max_len=1024 100 411 612 768

The crossover is at k ≈ head_max_len / 4, as you said. So the bullet now states that max_len - head_max_len is wrong in both directions — understating for a short question, and overstating for a high-cardinality one, where a caller sizing to it is truncated rather than merely conservative — and includes the MAX_CHOICE_OPTIONS = 100 reachability point. The bound sentence is gone. The 463/978 figures are unchanged.

On the test file:

  • test_readme_quotes_the_measured_figures now parses README.md and compares its quoted head/room figures against a live measurement. I checked it the way you did: with main's README under this test it now fails (1 failed, 4 passed), where before it passed with the stale wording. The docstring claim holds now.
  • The skip is visible in both runners — pytest.skip under pytest so -rs reports it, and a printed SKIP line as a script. Confirmed with LAYA_LAB_MODELS=/nonexistent: 7 passed, 0 failed, 1 skipped and SKIPPED [1] ...: english: no tokenizer under /nonexistent. No vacuous pass left.
  • test_dropping_the_minus_one_is_caught closes the gap you found: a state one token over the room must lose its tail, so the room is max_len - head_len - 1 and not max_len - head_len.
  • test_cap_based_figure_overstates_a_high_cardinality_question pins the new claim so it cannot regress.
  • Wired into ci.yml and release.yml next to the other plain suites.

8 passed as a script, 5 passed under pytest.

One thing I found while checking, not caused by this PR: python tests/test_truncation_direction.py fails as a plain script on clean main as well — it's pytest-only (no __main__ runner) and CI runs it via pytest, where it passes 5/5. Noted here only so it isn't mistaken for fallout from this change.

Thanks also for the #689 composition check and for triaging README.md:638, the -8 figures in laya/agent.py / laya/onnx_agent.py / laya/router.py, and docs/hooks/patterns.md:291 — I agree those describe predict_long's window budget, which is a different quantity and does match the code, so I've left all of them alone.

@Bruce-Yii Bruce-Yii left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The prose fix is right, and I want to be clear about that first: the cap-based figure is gone, the README now says the difference is unsafe in both directions, and that is the correct statement. That part of the original objection is resolved.

The test wiring is not, and it is the same objection as before rather than a new one.

I took tests/test_state_budget.py from this head and ran it twice against the same laya/ package, changing only which README was in the tree:

  • this head's test + main's README: 7 passed, 0 failed, 1 skipped
  • this head's test + this head's README: 7 passed, 0 failed, 1 skipped

Identical, including the skip. The suite cannot tell a stale README from a corrected one.

The reason is the skip path in test_readme_quotes_the_measured_figures. Both checkpoints are resolved through _real_tok, and when the tokenizer is absent the loop does skip(...) and continue — so head in heads and room in rooms are never evaluated. With neither checkpoint available, checked stays 0 and the function ends in skip("no checkpoints under %s"). The assertions are real, but nothing in an ordinary environment reaches them.

That matters because of where this test runs. tests/test_state_budget.py is now invoked by ci.yml and by release.yml, which is the right wiring. Neither workflow sets LAYA_LAB_MODELS or fetches the two checkpoints, so _models_dir() falls back to its default /home/parshu/projects/contri/laya-lab/models, and that path does not exist on a runner. Both workflows therefore execute a test whose only substantive assertion is guaranteed to skip. Previously the suite returned early; now it reports a skip, which is more visible but no more effective — the guard is still not guarding.

There is a second gap behind that one. _readme_quotes extracts exactly two patterns, **N-token** head and **N tokens for state**, and asserts only that the numbers it finds match a live measurement. It never looks at the prose. So if someone reintroduces a claim that the cap-based room is safe, or that the built head never exceeds the cap, this suite still passes — the sentence this PR exists to remove has no regression guard at all, checkpoint-independent or otherwise.

Both halves of that are cheap to close, and the first one needs no model at all:

  • assert on the README text directly that the unsafe-in-both-directions wording is present and the old safe/never-overestimates wording is absent. That is a plain string check on a file already being read, it runs everywhere including CI, and it is the assertion that would have caught the original false claim.
  • for the measured-figures check, either populate the checkpoints in the workflows, or stop presenting it as CI coverage. As written it reads like a gate and is not one. If it is meant as a local check, the skip message should say so.

I would rather not hold the README change itself — it is correct and can land as is. What I am asking for is that the two jobs which now run this file either gate something or stop implying that they do. Happy to re-check quickly once the text assertion is in.

@NandhaKishorM
NandhaKishorM merged commit 52570fa into NandhaKishorM:main Sep 29, 2026
@NandhaKishorM

Copy link
Copy Markdown
Owner

Passed checks and merged. Thank you @Parswanadh! It ships in 0.3.22.

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.

3 participants