From e087f2f1659cf68be3a0d3bd871e2b4f77570760 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Tue, 13 Oct 2026 23:35:31 +0800 Subject: [PATCH 01/32] valen: serve noul and score questions through the reference worker Extend the reference worker's contract with the two remaining Valen task types alongside choice: noul (fixed or custom true/false wording, answered as P(true) plus the probability pair and confidence) and score (2-10 ordered levels, per-level branches decoded as a cross-level softmax with the expected level, legend and concentration confidence, formulas copied from the pinned reference). Mixed-type requests share one state encoding; usage keeps the logical/compute split. Choice behavior is unchanged (the image/text parity blocks match the merged records byte for byte); noul and score parity against the pinned reference passes at 1e-6 on RTX 5060. 47 CPU tests; compare_reference gains --case noul|score|all with new example requests. --- README.md | 2 +- docs/supported-models.md | 2 +- recipe/README.md | 3 +- recipe/valen/compare_reference.py | 158 ++++++++++++---- recipe/valen/example-noul-request.json | 18 ++ recipe/valen/example-score-request.json | 15 ++ recipe/valen/reference.md | 173 +++++++++++++++-- recipe/valen/test_compare_reference.py | 239 ++++++++++++++++++++++-- src/models/valen/README.md | 116 ++++++++++-- src/models/valen/postprocess.py | 55 +++++- src/models/valen/preprocess.py | 57 +++++- src/models/valen/protocol.py | 128 ++++++++++--- tests/valen/test_postprocess.py | 80 ++++++++ tests/valen/test_preprocess.py | 119 +++++++++++- tests/valen/test_protocol.py | 137 +++++++++++++- tests/valen/test_worker.py | 54 ++++++ tests/valen/test_worker_server.py | 38 ++++ 17 files changed, 1257 insertions(+), 137 deletions(-) create mode 100644 recipe/valen/example-noul-request.json create mode 100644 recipe/valen/example-score-request.json diff --git a/README.md b/README.md index e4316082..935b0d43 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ pinned runtime for text state: | LAYA | [External worker](recipe/laya/README.md); [Python worker on Apple Silicon (MPS) and CPU](recipe/laya/apple-silicon.md); [CPU checkpoint reader](src/models/laya/README.md); [native Rust/CUDA worker on Hopper](recipe/laya/native/README.md) | | Cua-S1 4B 0.2 (`text` adapter) | [Python worker](recipe/cua_s1/text.md); [native worker](recipe/cua_s1/native.md), CUDA, run on sm_89 | | Cua-S1 4B 0.2 (`multimodal` adapter) | [Python CUDA worker](src/frontend/cua_s1.py); one PNG/JPEG screenshot, `choice`; native screenshot execution remains in progress | -| Valen-Preview-0923 | [Python reference worker](recipe/valen/reference.md); text state or one inline PNG/JPEG image, `choice`; author-run on RTX 5060 Laptop GPU (sm_120, WSL2), owner: community | +| Valen-Preview-0923 | [Python reference worker](recipe/valen/reference.md); text state or one inline PNG/JPEG image, `choice`/`noul`/`score`; author-run on RTX 5060 Laptop GPU (sm_120, WSL2), owner: community | | Open-Jev-27B-v1.1 | [Native Rust/CUDA worker](recipe/open_jev/native.md); eager independent text candidates; [H200 validation](recipe/open_jev/validation.md) | | Open-Jev-9B | The same [native Rust/CUDA worker](recipe/open_jev/native.md); eager independent text candidates; [reference comparison on sm_89](recipe/open_jev/validation-9b.md) | | autotrust/JEV-27B-VL | [Experimental Rust/CUDA worker](recipe/jev_vl/README.md); single-question text and offline-preencoded image inputs; [bounded H800 validation and limits](recipe/jev_vl/validation.md) | diff --git a/docs/supported-models.md b/docs/supported-models.md index 3ca05477..c4ffd016 100644 --- a/docs/supported-models.md +++ b/docs/supported-models.md @@ -15,7 +15,7 @@ Models that are being added are also tracked in issues labeled [new model](https | Cua-S1 4B 0.2, `text` adapter | [Reference worker](../recipe/cua_s1/text.md) on Transformers and PEFT | Unverified | Validated ([#13](https://github.com/ThinkFlowLab/system1-omni/pull/13)) | Unverified | Python 3.12, the versions in `requirements-text.txt` | | Cua-S1 4B 0.2, `text` adapter | [Native Rust worker](../recipe/cua_s1/native.md) on the [Qwen3.5 CUDA kernels](../src/backends/cuda/qwen3_5/README.md) | Not supported | Validated on compute capability 8.9 ([#19](https://github.com/ThinkFlowLab/system1-omni/pull/19), [#52](https://github.com/ThinkFlowLab/system1-omni/pull/52)) | Not supported | Compute capability 8.0 or newer, the CUDA toolkit to build, weights merged with `export_text_merged.py` | | Cua-S1 4B 0.2, `multimodal` adapter | Reference worker on Transformers and PEFT, [`src/frontend/cua_s1.py`](../src/frontend/cua_s1.py); no recipe yet | Not supported | Validated ([#17](https://github.com/ThinkFlowLab/system1-omni/pull/17), [#18](https://github.com/ThinkFlowLab/system1-omni/pull/18)) | Not supported | The state is one PNG or JPEG image; upstream's `weights.lock.json` next to the base weights | -| Valen-Preview-0923 | [Python reference worker](../recipe/valen/reference.md); text state or one inline PNG/JPEG image; `choice` questions | Not supported | Author-run on RTX 5060 Laptop (sm_120, WSL2): one text and one 2×2 PNG request; input and readout parity ≤1e-6 against reference (model outputs not compared). Owner: community. | Not supported | Python 3.12, pinned Valen source/checkpoint, Qwen3.5-2B base, BF16 CUDA | +| Valen-Preview-0923 | [Python reference worker](../recipe/valen/reference.md); text state or one inline PNG/JPEG image; `choice`, `noul`, and `score` questions | Not supported | Author-run on RTX 5060 Laptop (sm_120, WSL2): one text and one 2×2 PNG request; input and readout parity ≤1e-6 against reference (model outputs not compared); `noul`/`score` parity ≤1e-6 with exact legend and token agreement on the recipe's text fixtures. Owner: community. | Not supported | Python 3.12, pinned Valen source/checkpoint, Qwen3.5-2B base, BF16 CUDA | | Open-Jev-27B-v1.1 | [Native Rust/CUDA worker](../recipe/open_jev/native.md) on the shared Qwen3.5/3.8 executor | Not supported | Validated on H200 (sm_90) for the [74 single-candidate workload](../recipe/open_jev/validation.md) | Not supported | Compute capability 8.0 or newer, CUDA toolkit to build, exported merged weights and trained head | | Open-Jev-9B | The same [native Rust/CUDA worker](../recipe/open_jev/native.md) | Not supported | Validated on compute capability 8.9 against the reference for [253 requests](../recipe/open_jev/validation-9b.md) | Not supported | Compute capability 8.0 or newer, CUDA toolkit to build, exported merged weights and trained head | | autotrust/JEV-27B-VL | [Experimental native worker](../recipe/jev_vl/README.md), single-question API | Contract tests only; no CPU inference | H800 frozen-corpus check; [experimental scope and limits](../recipe/jev_vl/validation.md) | Not supported | Pinned merged checkpoint, rebuilt ABI 6 CUDA library; image inputs require offline Transformers preencoding | diff --git a/recipe/README.md b/recipe/README.md index 32f83cd0..aeba1508 100644 --- a/recipe/README.md +++ b/recipe/README.md @@ -12,7 +12,8 @@ For a first real decision, follow the [complete CPU walkthrough](../docs/getting - [Cua-S1 4B 0.2 text worker](cua_s1/text.md): download the pinned weights, start the worker and connect the Rust frontend. - [Valen-Preview-0923 reference worker](valen/reference.md): prepare the pinned - Preview checkpoint and Qwen base, start the text-or-image `choice` worker and verify the frontend. + Preview checkpoint and Qwen base, start the text-or-image `choice`/`noul`/`score` + worker and verify the frontend. - [Cua-S1 4B 0.2 native text worker](cua_s1/native.md): build the CUDA library and the Rust worker, export the merged weights and start the worker. - [Open-Jev native text worker](open_jev/native.md): export the merged text diff --git a/recipe/valen/compare_reference.py b/recipe/valen/compare_reference.py index 3e355924..43f0d1c3 100644 --- a/recipe/valen/compare_reference.py +++ b/recipe/valen/compare_reference.py @@ -4,18 +4,22 @@ Runs each request through two paths with the same pinned artifacts: - reference: a Valen-native record (a plain-string text state, or a local - image with a SHA-256 asset) compiled by the pinned ``Compiler`` and decoded - with a plain softmax plus Valen's confidence formula; + image with a SHA-256 asset) compiled by the pinned ``Compiler``; ``choice`` + cases are decoded with a plain softmax plus Valen's confidence formula, + while ``noul`` and ``score`` cases are decoded by the pinned reference + ``valen.evaluation.inference.answer`` module itself; - worker: the production ``frontend.valen.decide`` pipeline (protocol, preprocessing, executor, postprocessing). Both paths share one loaded model instance, so the comparison isolates input construction, candidate ordering, decision-head readout and probability normalization instead of checkpoint loading. The declared criteria are exact -decision agreement, equal token accounting and a probability tolerance of -1e-6 (``--tolerance``). Requires the pinned Valen source, the Preview -checkpoint, the Qwen3.5-2B base and a working torch device; see -recipe/valen/reference.md. +discrete agreement (candidate order, the chosen key, the score legend), equal +token accounting and a tolerance of 1e-6 (``--tolerance``) on probabilities +and on the scalar answer fields (``noul`` P(true), ``score`` expected level), +which the two softmax implementations never match bit-exactly. Requires the +pinned Valen source, the Preview checkpoint, the Qwen3.5-2B base and a +working torch device; see recipe/valen/reference.md. """ from __future__ import annotations @@ -51,7 +55,11 @@ def parse_args(argv=None): parser.add_argument("--base", required=True, help="Qwen3.5-2B base directory") parser.add_argument("--device", default="cuda") parser.add_argument("--dtype", choices=("bf16", "fp32"), default="bf16") - parser.add_argument("--case", choices=("image", "text", "both"), default="both") + parser.add_argument( + "--case", + choices=("image", "text", "noul", "score", "both", "all"), + default="both", + ) parser.add_argument( "--fixture", default=str(HERE / "example-request.json"), @@ -62,6 +70,16 @@ def parse_args(argv=None): default=None, help="text-case request body JSON file (default: the recipe text example)", ) + parser.add_argument( + "--noul-body", + default=str(HERE / "example-noul-request.json"), + help="noul-case request body JSON file (default: the recipe noul example)", + ) + parser.add_argument( + "--score-body", + default=str(HERE / "example-score-request.json"), + help="score-case request body JSON file (default: the recipe score example)", + ) parser.add_argument("--tolerance", type=float, default=PROBABILITY_TOLERANCE) return parser.parse_args(argv) @@ -78,20 +96,23 @@ def decode_image_data_url(url): return base64.b64decode(encoded, validate=True), expected[prefix] -def reference_questions(body): - """Rebuild the Valen question mapping straight from the fixture body.""" +def reference_questions(body, special_tokens=None): + """Rebuild the Valen question mapping straight from the fixture body. - return { - name: { - "type": "choice", - "instructions": question.get("instructions") or "", - "criteria": { - key: (label if label is not None else key) - for key, label in question["criteria"].items() - }, - } - for name, question in body["questions"].items() - } + The questions go through the worker's own ``parse_request`` + + ``question_record`` path, so the reference record cannot drift from the + one the worker compiles: custom ``noul`` wording becomes the same + ``true``/``false`` choice record, and unspecified sides get the same + fixed reference wording. + """ + + from models.valen.preprocess import question_record + from models.valen.protocol import DEFAULT_SPECIAL_TOKENS, parse_request + + request = parse_request( + body, special_tokens=special_tokens or DEFAULT_SPECIAL_TOKENS + ) + return {question.name: question_record(question) for question in request.questions} def reference_image_record(image_bytes, image_format, questions, media_dir): @@ -140,14 +161,22 @@ def reference_confidence(probabilities): def compare_case(name, worker_response, reference, tolerance=PROBABILITY_TOLERANCE): """Compare one worker response against one reference result. - Returns (report lines, passed). + ``choice`` answers must agree on the chosen key; ``noul`` answers on + P(true); ``score`` answers on the expected level and the level legend. + All scalar comparisons use the declared tolerance because the worker and + the pinned reference normalize probabilities through different softmax + implementations. Returns (report lines, passed). """ lines = [f"case: {name}", "reference model: Valen", f"worker model: {worker_response['model']}"] problems = [] + legend_problems = [] decision_exact = True + legend_exact = True max_prob_delta = 0.0 max_conf_delta = 0.0 + max_answer_delta = 0.0 + scalar_answers = False for qid, ref_keys in reference["keys"].items(): answer = worker_response["answers"][qid] worker_keys = list(answer["probabilities"]) @@ -158,12 +187,52 @@ def compare_case(name, worker_response, reference, tolerance=PROBABILITY_TOLERAN ref_probs = reference["probabilities"][qid] for key, worker_p, ref_p in zip(ref_keys, answer["probabilities"].values(), ref_probs): max_prob_delta = max(max_prob_delta, abs(worker_p - ref_p)) - decision = ref_keys[max(range(len(ref_probs)), key=ref_probs.__getitem__)] - if answer["choice"] != decision: - problems.append(f"{qid}: worker chose {answer['choice']!r}, reference {decision!r}") - decision_exact = False - ref_confidence = reference_confidence(ref_probs) - max_conf_delta = max(max_conf_delta, abs(answer["confidence"] - ref_confidence)) + # The pinned reference decode when the harness ran with the pinned + # source; synthetic references may omit it and fall back to the + # shared probabilities and formulas. + ref_answer = reference.get("answers", {}).get(qid) + if answer["type"] == "noul": + scalar_answers = True + if ref_answer is not None and "noul" in ref_answer: + ref_p_true = ref_answer["noul"] + elif ref_answer is not None: + # Custom-wording noul compiles as a choice record, so the + # pinned decoder answers it in choice shape. + ref_p_true = ref_answer["probabilities"]["true"] + else: + ref_p_true = ref_probs[ref_keys.index("true")] + max_answer_delta = max(max_answer_delta, abs(answer["noul"] - ref_p_true)) + if ref_answer is not None and "confidence" in ref_answer: + ref_confidence = ref_answer["confidence"] + else: + # The reference noul answer carries no confidence; the worker + # exposes Valen's choice formula evaluated on the pair. + ref_confidence = reference_confidence(ref_probs) + max_conf_delta = max(max_conf_delta, abs(answer["confidence"] - ref_confidence)) + elif answer["type"] == "score": + scalar_answers = True + ref_score = ( + ref_answer["score"] + if ref_answer is not None + else sum(index * p for index, p in enumerate(ref_probs)) + ) + max_answer_delta = max(max_answer_delta, abs(answer["score"] - ref_score)) + if ref_answer is not None and "legend" in ref_answer: + if answer["legend"] != ref_answer["legend"]: + legend_problems.append(f"{qid}: score legend mismatch") + legend_exact = False + if ref_answer is not None and "confidence" in ref_answer: + ref_confidence = ref_answer["confidence"] + else: + ref_confidence = reference_confidence(ref_probs) + max_conf_delta = max(max_conf_delta, abs(answer["confidence"] - ref_confidence)) + else: + decision = ref_keys[max(range(len(ref_probs)), key=ref_probs.__getitem__)] + if answer["choice"] != decision: + problems.append(f"{qid}: worker chose {answer['choice']!r}, reference {decision!r}") + decision_exact = False + ref_confidence = reference_confidence(ref_probs) + max_conf_delta = max(max_conf_delta, abs(answer["confidence"] - ref_confidence)) usage = worker_response["usage"] compute = worker_response["internal_usage"]["compute_tokens"] @@ -184,6 +253,8 @@ def compare_case(name, worker_response, reference, tolerance=PROBABILITY_TOLERAN f" {reference['compute_tokens']}" ) lines.append(f"max probability delta: {max_prob_delta:.10e}") + if scalar_answers: + lines.append(f"max answer delta: {max_answer_delta:.10e}") lines.append(f"confidence delta: {max_conf_delta:.10e}") lines.append( "decision agreement: " @@ -193,16 +264,30 @@ def compare_case(name, worker_response, reference, tolerance=PROBABILITY_TOLERAN lines.append( f"probability tolerance <= {tolerance:g}: " + ("PASS" if prob_ok else "FAIL") ) + if scalar_answers: + answer_ok = max_answer_delta <= tolerance + lines.append( + f"answer tolerance <= {tolerance:g}: " + ("PASS" if answer_ok else "FAIL") + ) + if any("legend" in answer for answer in worker_response["answers"].values()): + lines.append( + "legend agreement: " + + ("PASS" if legend_exact else "FAIL: " + "; ".join(legend_problems)) + ) lines.append("token accounting: " + ("PASS" if tokens_exact else "FAIL")) - return lines, decision_exact and prob_ok and tokens_exact + passed = decision_exact and legend_exact and prob_ok and tokens_exact + if scalar_answers: + passed = passed and max_answer_delta <= tolerance + return lines, passed def run_reference(model, base_processor, record, media_root, max_length, media_kwargs): - """Compile a Valen-native record and decode it with a plain softmax.""" + """Compile a Valen-native record and decode it with the pinned modules.""" import torch from valen.data.compiler import Compiler + from valen.evaluation.inference import answer as reference_answer compiler = Compiler( base_processor, @@ -213,14 +298,19 @@ def run_reference(model, base_processor, record, media_root, max_length, media_k state = compiler.compile(record) keys = {} probabilities = {} + answers = {} with torch.no_grad(): for question in state.questions: logits = model(question).float().cpu() probabilities[question.qid] = torch.softmax(logits, dim=-1).tolist() keys[question.qid] = list(question.keys) + # The pinned decoder is the answer-field authority, so every + # question is decoded by it as well as by the plain softmax. + answers[question.qid] = reference_answer(question, logits) return { "keys": keys, "probabilities": probabilities, + "answers": answers, "logical_tokens": state.logical_tokens, "compute_tokens": state.compute_tokens, } @@ -245,19 +335,23 @@ def main(argv=None): executor = ValenExecutor(checkpoint, Path(args.base), args.device, args.dtype) cases = [] - if args.case in ("image", "both"): + if args.case in ("image", "both", "all"): cases.append(("image", json.loads(Path(args.fixture).read_text(encoding="utf-8")))) - if args.case in ("text", "both"): + if args.case in ("text", "both", "all"): if args.text_body: cases.append(("text", json.loads(Path(args.text_body).read_text(encoding="utf-8")))) else: cases.append(("text", DEFAULT_TEXT_BODY)) + if args.case in ("noul", "all"): + cases.append(("noul", json.loads(Path(args.noul_body).read_text(encoding="utf-8")))) + if args.case in ("score", "all"): + cases.append(("score", json.loads(Path(args.score_body).read_text(encoding="utf-8")))) failed = 0 try: for name, body in cases: worker_response = decide(json.dumps(body).encode("utf-8"), processor, executor) - questions = reference_questions(body) + questions = reference_questions(body, processor.special_tokens) with TemporaryDirectory(prefix="valen-parity-") as directory: media_root = Path(directory) if name == "image": diff --git a/recipe/valen/example-noul-request.json b/recipe/valen/example-noul-request.json new file mode 100644 index 00000000..48d57fdb --- /dev/null +++ b/recipe/valen/example-noul-request.json @@ -0,0 +1,18 @@ +{ + "model": "valen-preview-0923", + "state": "The card was charged twice for one order.", + "questions": { + "duplicate": { + "type": "noul", + "instructions": "Was the card charged twice for one order?" + }, + "authorized": { + "type": "noul", + "instructions": "Did the customer authorize both charges?", + "criteria": { + "true": "Both charges were authorized by the customer.", + "false": "At least one charge was unauthorized." + } + } + } +} diff --git a/recipe/valen/example-score-request.json b/recipe/valen/example-score-request.json new file mode 100644 index 00000000..e077afab --- /dev/null +++ b/recipe/valen/example-score-request.json @@ -0,0 +1,15 @@ +{ + "model": "valen-preview-0923", + "state": "The card was charged twice for one order.", + "questions": { + "severity": { + "type": "score", + "instructions": "How severe is the billing error?", + "criteria": [ + "No billing error: every charge is correct.", + "Minor error: a single duplicated charge.", + "Major error: repeated duplicated charges." + ] + } + } +} diff --git a/recipe/valen/reference.md b/recipe/valen/reference.md index 863d07a0..e0263b18 100644 --- a/recipe/valen/reference.md +++ b/recipe/valen/reference.md @@ -17,19 +17,21 @@ The validated slice is: - the Python reference worker behind the Rust frontend; - text state (a non-empty string; objects and arrays are serialized to JSON text) or one inline PNG or JPEG image in `state.image`; -- textual instructions and criteria for `choice` questions; +- textual instructions and criteria for `choice`, `noul`, and `score` + questions, freely mixed in one request against one shared state; - BF16 CUDA inference with the pinned Preview checkpoint. Image and text state are serving-validated on the GPU host, with recorded reference parity - for both. + for all three question types. -The current worker does not implement `noul`, `score`, video, multiple images, -remote image URLs, or a native Rust/CUDA Valen executor. +The current worker does not implement video, multiple images, remote image +URLs, or a native Rust/CUDA Valen executor. The current protocol limits requests to an 8 MiB JSON body, one PNG/JPEG image of at most 4 MiB, 2048 pixels per side, 1,048,576 pixels total, and a 200:1 maximum aspect ratio. Text state is limited to 16,384 characters, the same limit as combined instruction and criterion text. A request may contain at -most 8 questions and 255 candidates per question. +most 8 questions, 255 named candidates per `choice` question, and an ordered +list of 2 to 10 level descriptions per `score` question. The first slice follows [issue #84](https://github.com/ThinkFlowLab/system1-omni/issues/84): establish @@ -99,7 +101,7 @@ Pillow: 12.3.0 av: 16.1.0 GPU capability: sm_120 dtype: bfloat16 -system1-omni: 2fd8ce9 (GPU serving and smoke records); parity blocks re-verified verbatim at c9fb2d2 +system1-omni: 2fd8ce9 (GPU serving and smoke records); parity blocks re-verified verbatim at c9fb2d2; noul/score records collected on the same host from the spec-01 working tree (branch model/valen-reference-serving, uncommitted) ~~~ FP32 and BF16 small-operator checks passed. Startup memory was approximately @@ -250,6 +252,83 @@ Observed response, same host as the image fixture: } ~~~ +A yes/no judgment uses `noul`, optionally replacing the reference's fixed +true/false wording through `criteria`. The complete runnable request is in +[`example-noul-request.json`](example-noul-request.json); it asks one +fixed-wording and one custom-wording question against one state: + +~~~sh +curl -sS http://127.0.0.1:8000/v1/systemone \ + -H 'Content-Type: application/json' \ + --data-binary @recipe/valen/example-noul-request.json +~~~ + +Observed response, same host as the fixtures above: + +~~~json +{ + "model": "valen-preview-0923", + "answers": { + "duplicate": { + "type": "noul", + "noul": 0.19441015095399058, + "probabilities": {"true": 0.19441015095399058, "false": 0.8055898490460095}, + "confidence": 0.611179698092019 + }, + "authorized": { + "type": "noul", + "noul": 0.3848572797166159, + "probabilities": {"true": 0.3848572797166159, "false": 0.6151427202833842}, + "confidence": 0.23028544056676847 + } + }, + "usage": {"input_tokens": 109, "output_tokens": 0}, + "internal_usage": {"compute_tokens": 123} +} +~~~ + +`noul` is `P(true)` — the reference's own answer field — and the full +two-candidate pair is exposed alongside it. The custom-wording question +compiles through the equivalent `true`/`false` choice record, so its branch +adds its own suffix to `internal_usage.compute_tokens`. + +A graded assessment uses `score` with an ordered list of 2 to 10 level +descriptions. The complete runnable request is in +[`example-score-request.json`](example-score-request.json): + +~~~sh +curl -sS http://127.0.0.1:8000/v1/systemone \ + -H 'Content-Type: application/json' \ + --data-binary @recipe/valen/example-score-request.json +~~~ + +Observed response, same host: + +~~~json +{ + "model": "valen-preview-0923", + "answers": {"severity": { + "type": "score", + "probabilities": {"0": 0.17458891628733877, "1": 0.6222512480902359, "2": 0.2031598356224252}, + "score": 1.0285709193350863, + "legend": { + "0": "No billing error: every charge is correct.", + "1": "Minor error: a single duplicated charge.", + "2": "Major error: repeated duplicated charges." + }, + "confidence": 0.43337687213535403 + }}, + "usage": {"input_tokens": 107, "output_tokens": 0}, + "internal_usage": {"compute_tokens": 135} +} +~~~ + +Each score level compiles to its own isolated branch (it never sees the other +levels or its own level ID), the K per-level decision logits are softmaxed +across branches, and `score` reports the expected level with the reference's +distribution-concentration `confidence`. The three branches explain +`compute_tokens` 135 against `input_tokens` 107. + ## Start and verify the Rust frontend ~~~sh @@ -268,7 +347,8 @@ curl -sS http://127.0.0.1:8080/v1/systemone \ ~~~ The generic `recipe/compare_with_backend.py` also sends `score`/`noul` cases, -which this worker rejects, so compare the Valen fixture directly: +which this worker now answers; the byte-identical comparison below uses the +Valen fixture directly: ~~~sh curl -fsS http://127.0.0.1:8000/v1/systemone \ @@ -282,7 +362,9 @@ cmp /tmp/valen-worker.json /tmp/valen-frontend.json The validated runs returned 200 from both endpoints with matching JSON content types and byte-identical response bodies, for the image fixture above and for -the text-only example. +the text-only example. The noul and score fixtures were re-checked the same +way on the same host from the spec-01 working tree: byte-identical through +the frontend for all three fixtures. ## Error behavior @@ -309,7 +391,7 @@ python recipe/valen/test_compare_reference.py Validated results: ~~~text -pytest tests/valen: 29 passed in 0.67s +pytest tests/valen: 47 passed in 1.21s compileall: passed cargo fmt --all --check: passed frontend integration: 12 passed; 0 failed @@ -354,15 +436,65 @@ probability tolerance <= 1e-06: PASS token accounting: PASS ~~~ +The `noul` case covers both wordings in +[`example-noul-request.json`](example-noul-request.json) — the fixed-wording +question compiles as the reference's native noul record, the custom-wording +question as the equivalent `true`/`false` choice record — against the pinned +reference decoder: + +~~~text +case: noul +reference model: Valen +worker model: valen-preview-0923 +input_tokens: 109 == 109 +output_tokens: 0 == 0 +compute_tokens: 123 == 123 +max probability delta: 4.7029371064e-08 +max answer delta: 1.2620447742e-08 +confidence delta: 2.5240895818e-08 +decision agreement: PASS +probability tolerance <= 1e-06: PASS +answer tolerance <= 1e-06: PASS +token accounting: PASS +~~~ + +The `score` case compares the three-level fixture in +[`example-score-request.json`](example-score-request.json): per-branch +isolation, the cross-branch softmax, the expected level, the legend and the +distribution-concentration confidence: + +~~~text +case: score +reference model: Valen +worker model: valen-preview-0923 +input_tokens: 107 == 107 +output_tokens: 0 == 0 +compute_tokens: 135 == 135 +max probability delta: 2.4111302177e-08 +max answer delta: 1.4517812730e-08 +confidence delta: 5.5582444203e-09 +decision agreement: PASS +probability tolerance <= 1e-06: PASS +answer tolerance <= 1e-06: PASS +legend agreement: PASS +token accounting: PASS +~~~ + The reference model field is `Valen`; the system1-omni worker deliberately uses `valen-preview-0923` as its stable serving identity. Reproduce with [`compare_reference.py`](compare_reference.py). The script loads one pinned model instance and drives each request through two paths: a -Valen-native record compiled by the pinned `Compiler` and decoded with a plain -softmax, and the production `frontend.valen.decide` pipeline. The decision -criteria are exact choice agreement, equal token accounting and the declared -probability tolerance (default `1e-6`, `--tolerance` to override): +Valen-native record compiled by the pinned `Compiler` — built by the worker's +own record rules so the reference input cannot drift from the served one — +and the production `frontend.valen.decide` pipeline. `noul` and `score` +answers are additionally decoded by the pinned reference answer module +(`valen.evaluation.inference.answer`). The decision criteria are exact +discrete agreement (candidate order, the chosen key, the score legend), equal +token accounting and the declared tolerance (default `1e-6`, `--tolerance` to +override) on probabilities and on the scalar answer fields (`noul` P(true), +`score` expected level), which the worker's pure-Python softmax and the +reference's torch softmax never match bit-exactly: ~~~sh PYTHONPATH=src .venv/bin/python recipe/valen/compare_reference.py \ @@ -372,11 +504,22 @@ PYTHONPATH=src .venv/bin/python recipe/valen/compare_reference.py \ PYTHONPATH=src .venv/bin/python recipe/valen/compare_reference.py \ --valen-source "$VALEN_SRC" --checkpoint "$VALEN_CKPT" \ --base "$VALEN_BASE" --device cuda --dtype bf16 --case text + +PYTHONPATH=src .venv/bin/python recipe/valen/compare_reference.py \ + --valen-source "$VALEN_SRC" --checkpoint "$VALEN_CKPT" \ + --base "$VALEN_BASE" --device cuda --dtype bf16 --case noul + +PYTHONPATH=src .venv/bin/python recipe/valen/compare_reference.py \ + --valen-source "$VALEN_SRC" --checkpoint "$VALEN_CKPT" \ + --base "$VALEN_BASE" --device cuda --dtype bf16 --case score ~~~ The image case uses [`example-request.json`](example-request.json); the text case uses the recipe's text example unless `--text-body` points elsewhere. -Both blocks above are verbatim output of the committed tool. +The `noul` and `score` cases use [`example-noul-request.json`](example-noul-request.json) +and [`example-score-request.json`](example-score-request.json) unless +`--noul-body`/`--score-body` point elsewhere; `--case all` runs all four in +one model load. All blocks above are verbatim output of the committed tool. ## Troubleshooting and limits @@ -392,4 +535,4 @@ Both blocks above are verbatim output of the committed tool. - `/health` is exposed only after model loading and real warmup complete. - The memory and smoke-response observations are host-specific; they are not general accuracy or performance claims. -- Video, native execution, `noul`, and `score` remain outside this recipe. +- Video and native execution remain outside this recipe. diff --git a/recipe/valen/test_compare_reference.py b/recipe/valen/test_compare_reference.py index d124f09d..16e0691d 100644 --- a/recipe/valen/test_compare_reference.py +++ b/recipe/valen/test_compare_reference.py @@ -3,7 +3,8 @@ Checks the pure comparison helpers without the pinned Valen source, model weights or a GPU: the reference confidence formula, data-URL decoding, the -reference record builders, and the PASS/FAIL verdicts of compare_case. +reference record builders (including the noul/score record rules), and the +PASS/FAIL verdicts of compare_case. python recipe/valen/test_compare_reference.py """ @@ -12,32 +13,36 @@ import base64 import hashlib +import json import sys import tempfile from pathlib import Path HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(HERE.parents[1] / "src")) import compare_reference as tool -def worker_response(probabilities, choice, confidence, input_tokens=17, compute_tokens=17): +def worker_response(answers, input_tokens=17, compute_tokens=17): return { "model": "valen-preview-0923", - "answers": { - "move": { - "type": "choice", - "choice": choice, - "probabilities": probabilities, - "confidence": confidence, - } - }, + "answers": answers, "usage": {"input_tokens": input_tokens, "output_tokens": 0}, "internal_usage": {"compute_tokens": compute_tokens}, } +def choice_answer(probabilities, choice, confidence): + return { + "type": "choice", + "choice": choice, + "probabilities": probabilities, + "confidence": confidence, + } + + REFERENCE = { "keys": {"move": ["up", "down"]}, "probabilities": {"move": [0.25, 0.75]}, @@ -102,19 +107,77 @@ def check(name: str, condition: bool, detail: str = "") -> None: "text record uses the compiler's plain-string state", record["request"]["state"] == "plain context" and record["request"]["questions"] is questions - and "assets" not in record, + and "assets" not in record ) + + # 3b. Reference question records reuse the worker's own record rules. + def request_body(question): + return { + "model": "valen-preview-0923", + "state": "The card was charged twice for one order.", + "questions": {"q": question}, + } + check( "reference questions map null labels to their keys", - tool.reference_questions( - {"questions": {"q": {"instructions": None, - "criteria": {"a": None, "b": "B"}}}} - )["q"]["criteria"] == {"a": "a", "b": "B"}, + tool.reference_questions(request_body( + {"type": "choice", "instructions": "Choose.", + "criteria": {"a": None, "b": "B"}}))["q"]["criteria"] == {"a": "a", "b": "B"}, + ) + check( + "noul without wording stays the reference's native noul record", + tool.reference_questions(request_body( + {"type": "noul", "instructions": "Was it duplicated?"}))["q"] + == {"type": "noul", "instructions": "Was it duplicated?"}, + ) + custom = tool.reference_questions(request_body( + {"type": "noul", "instructions": "Was it duplicated?", + "criteria": {"true": "Duplicated.", "false": None}}))["q"] + check( + "custom noul wording becomes a true/false choice record", + custom["type"] == "choice" + and custom["criteria"]["true"] == "Duplicated." + and custom["criteria"]["false"] == "False / 否:不满足问题中的条件。", + ) + check( + "score levels pass through as an ordered list", + tool.reference_questions(request_body( + {"type": "score", "instructions": "Grade.", + "criteria": ["low", "high"]}))["q"] + == {"type": "score", "instructions": "Grade.", "criteria": ["low", "high"]}, + ) + + # 3c. The committed noul and score fixtures build valid records. + noul_fixture = json.loads( + (HERE / "example-noul-request.json").read_text(encoding="utf-8") + ) + noul_records = tool.reference_questions(noul_fixture) + check( + "noul fixture builds one native and one worded record", + noul_records["duplicate"] == { + "type": "noul", + "instructions": "Was the card charged twice for one order?", + } + and noul_records["authorized"]["type"] == "choice" + and set(noul_records["authorized"]["criteria"]) == {"true", "false"}, + ) + score_fixture = json.loads( + (HERE / "example-score-request.json").read_text(encoding="utf-8") + ) + score_records = tool.reference_questions(score_fixture) + check( + "score fixture builds one three-level score record", + score_records["severity"]["type"] == "score" + and len(score_records["severity"]["criteria"]) == 3, ) # 4. A matching worker response passes every verdict. lines, ok = tool.compare_case( - "text", worker_response({"up": 0.25, "down": 0.75}, "down", 0.5), REFERENCE + "text", + worker_response( + {"move": choice_answer({"up": 0.25, "down": 0.75}, "down", 0.5)} + ), + REFERENCE, ) report = "\n".join(lines) check( @@ -130,14 +193,18 @@ def check(name: str, condition: bool, detail: str = "") -> None: # 5. A tiny delta stays inside the declared tolerance. lines, ok = tool.compare_case( "text", - worker_response({"up": 0.25 + 1e-9, "down": 0.75 - 1e-9}, "down", 0.5), + worker_response( + {"move": choice_answer({"up": 0.25 + 1e-9, "down": 0.75 - 1e-9}, "down", 0.5)} + ), REFERENCE, ) check("delta within tolerance passes", ok, "\n".join(lines)) # 6. Each failure mode fails on its own. lines, ok = tool.compare_case( - "text", worker_response({"up": 0.6, "down": 0.4}, "up", 0.2), REFERENCE + "text", + worker_response({"move": choice_answer({"up": 0.6, "down": 0.4}, "up", 0.2)}), + REFERENCE, ) check( "probability and decision mismatch fails", @@ -148,7 +215,11 @@ def check(name: str, condition: bool, detail: str = "") -> None: ) lines, ok = tool.compare_case( - "text", worker_response({"up": 0.25, "down": 0.75}, "down", 0.5, input_tokens=18), + "text", + worker_response( + {"move": choice_answer({"up": 0.25, "down": 0.75}, "down", 0.5)}, + input_tokens=18, + ), REFERENCE, ) check( @@ -159,7 +230,11 @@ def check(name: str, condition: bool, detail: str = "") -> None: ) lines, ok = tool.compare_case( - "text", worker_response({"down": 0.75, "up": 0.25}, "down", 0.5), REFERENCE + "text", + worker_response( + {"move": choice_answer({"down": 0.75, "up": 0.25}, "down", 0.5)} + ), + REFERENCE, ) check( "candidate order mismatch fails", @@ -167,6 +242,130 @@ def check(name: str, condition: bool, detail: str = "") -> None: "\n".join(lines), ) + # 7. noul answers compare P(true) with tolerance. + noul_reference = { + "keys": {"ask": ["true", "false"]}, + "probabilities": {"ask": [0.75, 0.25]}, + # The pinned decoder answers native noul with P(true) only; custom + # wording decodes as a choice pair instead. + "answers": {"ask": {"type": "noul", "noul": 0.75}}, + "logical_tokens": 17, + "compute_tokens": 17, + } + + def noul_answer(p_true, confidence): + return { + "type": "noul", + "noul": p_true, + "probabilities": {"true": p_true, "false": 1.0 - p_true}, + "confidence": confidence, + } + + lines, ok = tool.compare_case( + "noul", worker_response({"ask": noul_answer(0.75, 0.5)}), noul_reference + ) + report = "\n".join(lines) + check( + "noul exact match passes with the answer tolerance", + ok + and "answer tolerance <= 1e-06: PASS" in report + and "legend agreement" not in report, + report, + ) + + lines, ok = tool.compare_case( + "noul", + worker_response({"ask": noul_answer(0.75 + 1e-4, 0.5)}), + noul_reference, + ) + check( + "noul answer drift beyond tolerance fails", + not ok and "answer tolerance <= 1e-06: FAIL" in "\n".join(lines), + "\n".join(lines), + ) + + choice_shaped = dict(noul_reference) + choice_shaped["answers"] = { + "ask": {"type": "choice", "probabilities": {"true": 0.75, "false": 0.25}, + "choice": "true", "confidence": 0.5} + } + lines, ok = tool.compare_case( + "noul", worker_response({"ask": noul_answer(0.75, 0.5)}), choice_shaped + ) + check( + "custom-wording noul compares against the choice-shaped decode", + ok and "confidence delta: 0.0000000000e+00" in "\n".join(lines), + "\n".join(lines), + ) + + # 8. score answers compare the expected level, legend and confidence. + score_reference = { + "keys": {"grade": ["0", "1", "2"]}, + "probabilities": {"grade": [0.7, 0.2, 0.1]}, + "answers": { + "grade": { + "type": "score", + "probabilities": {"0": 0.7, "1": 0.2, "2": 0.1}, + "score": 0.4, + "legend": {"0": "No error", "1": "A minor error", "2": "A major error"}, + "confidence": 0.4, + } + }, + "logical_tokens": 17, + "compute_tokens": 51, + } + + def score_answer(score, legend, confidence): + return { + "type": "score", + "probabilities": {"0": 0.7, "1": 0.2, "2": 0.1}, + "score": score, + "legend": legend, + "confidence": confidence, + } + + legend = {"0": "No error", "1": "A minor error", "2": "A major error"} + lines, ok = tool.compare_case( + "score", + worker_response({"grade": score_answer(0.4, legend, 0.4)}, compute_tokens=51), + score_reference, + ) + report = "\n".join(lines) + check( + "score exact match passes with legend agreement", + ok + and "answer tolerance <= 1e-06: PASS" in report + and "legend agreement: PASS" in report, + report, + ) + + wrong_legend = dict(legend, **{"2": "A catastrophic error"}) + lines, ok = tool.compare_case( + "score", + worker_response( + {"grade": score_answer(0.4, wrong_legend, 0.4)}, compute_tokens=51 + ), + score_reference, + ) + check( + "score legend mismatch fails", + not ok + and "legend agreement: FAIL" in "\n".join(lines) + and "score legend mismatch" in "\n".join(lines), + "\n".join(lines), + ) + + lines, ok = tool.compare_case( + "score", + worker_response({"grade": score_answer(0.5, legend, 0.4)}, compute_tokens=51), + score_reference, + ) + check( + "score expected-level drift fails the answer tolerance", + not ok and "answer tolerance <= 1e-06: FAIL" in "\n".join(lines), + "\n".join(lines), + ) + failed = 0 for name, passed, detail in results: print(f" {'ok ' if passed else 'FAIL'} {name}") diff --git a/src/models/valen/README.md b/src/models/valen/README.md index 8d107ce5..30053395 100644 --- a/src/models/valen/README.md +++ b/src/models/valen/README.md @@ -2,8 +2,9 @@ This directory contains the first System1-Omni integration slice for Valen-Preview-0923. It is a reference-serving boundary only: the Python worker, -processor, executor, and postprocessor are implemented for the pinned text and -single-image `choice` slice. Native CUDA execution is not part of this slice. +processor, executor, and postprocessor serve the pinned text and single-image +`choice`, `noul`, and `score` questions. Native CUDA execution is not part of +this slice. ## Pinned artifacts @@ -29,9 +30,14 @@ does not contain the frozen Qwen base model. The published config uses criteria are limited to 16,384 characters; - `instructions` must be non-empty after trimming; criterion keys and texts must be non-empty after trimming (a null criterion text defaults to its key); -- `choice` questions only, at most 8 per request; -- up to 255 named candidates per question; -- no `noul`, `score`, video, multi-image, or remote URL inputs; +- `choice`, `noul` and `score` questions, at most 8 per request, freely mixed + against one shared state; +- `choice`: up to 255 named candidates per question; +- `noul`: fixed `true`/`false` candidates; `criteria` is optional and may + replace either side's wording (`{"true": ..., "false": ...}`, null keeps + the reference's fixed wording); +- `score`: an ordered list of 2 to 10 level descriptions in `criteria`; +- no video, multi-image, or remote URL inputs; - no token generation: the decision head returns candidate logits directly. The public request keeps the Cua-S1-compatible shape: @@ -66,15 +72,69 @@ A text-only request replaces the image object with plain text: } ~~~ +A yes/no judgment uses `noul`: + +~~~json +{ + "model": "valen-preview-0923", + "state": "The card was charged twice for one order.", + "questions": { + "duplicate": { + "type": "noul", + "instructions": "Was the card charged twice for one order?" + } + } +} +~~~ + +A graded assessment uses `score` with an ordered level list: + +~~~json +{ + "model": "valen-preview-0923", + "state": "The card was charged twice for one order.", + "questions": { + "severity": { + "type": "score", + "instructions": "How severe is the billing error?", + "criteria": [ + "No billing error: every charge is correct.", + "Minor error: a single duplicated charge.", + "Major error: repeated duplicated charges." + ] + } + } +} +~~~ + Preprocessing validates and decodes the data URL, writes it to a request-scoped directory, hashes the bytes, and converts it to Valen's local-media `messages` record. Text state skips materialization and compiles a text-only -user message with no assets. The worker owns that directory's lifetime. Postprocessing -uses Valen's original choice confidence formula for this reference slice: +user message with no assets. The worker owns that directory's lifetime. + +The pinned compiler builds every question type from the same record family: +`choice` and `noul` compile to one branch holding all candidates, while +`score` compiles one isolated branch per level — each branch sees only its own +level description, never the level ID or the other levels — and the K +per-level decision logits are softmaxed across branches. A `noul` question +without custom wording compiles as the reference's native noul record with +the pinned schema's fixed candidate wording. Custom true/false wording cannot +flow through a noul record (the pinned `candidates` ignores noul criteria), so +it compiles as an equivalent `choice` record keyed `true`/`false` with +unspecified sides filled from the same fixed wording: the same one-branch, +two-candidate structure, so `P(true)` still comes from the two-candidate +softmax. + +Postprocessing keeps Valen's original formulas for this reference slice: ~~~text -K = 1: confidence = 1 -K > 1: confidence = max(0, (max(p) - 1/K) / (1 - 1/K)) +choice/noul confidence (n candidates): + n = 1: 1 + n > 1: max(0, (max(p) - 1/n) / (1 - 1/n)) # noul is the n = 2 case +score confidence (K ordered levels, mode = argmax p): + distance = sum_i p_i * |i - mode| + uniform_mad = (sum_i |i - (K-1)/2|) / K + confidence = max(0, 1 - distance / uniform_mad) ~~~ This confidence choice is intentionally temporary. The shared `/v1/systemone` @@ -82,17 +142,32 @@ contract, including confidence semantics, error details, busy behavior, model identity, and usage fields, remains coordinated with [system1-omni#61](https://github.com/ThinkFlowLab/system1-omni/issues/61). -Each answer is `{"type": "choice", "choice": ..., "probabilities": ..., -"confidence": ...}`: `choice` is the candidate with the highest probability, -ties go to the earliest candidate in request order, and `probabilities` maps -every candidate key to its probability in request order. +Each `choice` answer is `{"type": "choice", "choice": ..., +"probabilities": ..., "confidence": ...}`: `choice` is the candidate with the +highest probability, ties go to the earliest candidate in request order, and +`probabilities` maps every candidate key to its probability in request order. + +Each `noul` answer is `{"type": "noul", "noul": ..., +"probabilities": {"true": ..., "false": ...}, "confidence": ...}`: `noul` is +`P(true)` — the field name matches Valen's reference answer module — and the +full two-candidate pair is exposed so clients can derive their own +quantities. The reference's own noul answer carries only `P(true)`; the pair +and confidence are this worker's documented extension. + +Each `score` answer is `{"type": "score", "probabilities": ..., +"score": ..., "legend": ..., "confidence": ...}` with the reference's field +names: `probabilities` maps the level keys `"0"`… to their cross-branch +softmax probability, `score` is the expected level `sum(i * p_i)`, and +`legend` maps each level key back to its request-order description. Values +are emitted unrounded, like every other answer field. The response `model` is `valen-preview-0923`, the worker's stable serving identity. `usage.input_tokens` counts the compiled prompt's logical tokens: the shared state tokens plus each question's task suffix, counted once. `usage.output_tokens` is 0; the decision head returns logits without generation. `internal_usage.compute_tokens` counts the full per-question -branch lengths the executor runs, with the shared state repeated per branch. +branch lengths the executor runs, with the shared state repeated per branch — +a K-level `score` question therefore counts K branches. ## Errors @@ -111,14 +186,17 @@ and `503` that another inference is running. The status is `422` when a well-formed request cannot be evaluated: - a `model` other than `valen-preview-0923`; -- a `score` or `noul` question, or unsupported request or question fields; +- a question whose `type` is not `choice`, `noul` or `score`, or unsupported + request or question fields; - text over its limit, or containing a tokenizer control token — the media tokens such as `<|image_pad|>` at minimum, plus the loaded tokenizer's full special-token set (for example `<|im_end|>`) when the worker is serving; - this guard covers text state, instructions, criterion texts and option keys - (candidate names enter the prompt); -- empty or whitespace-only `instructions`, option keys, criterion texts, or - text state; + this guard covers text state, instructions, criterion texts, level + descriptions and option keys (candidate names enter the prompt); +- empty or whitespace-only `instructions`, option keys, criterion texts, + noul descriptions, score level descriptions, or text state; +- `noul` criteria that name anything other than `true`/`false`, or `score` + criteria that are not an ordered list of 2 to 10 level descriptions; - an empty text state, or a state that is neither an image data URL nor text; - an image that is not a single-frame PNG/JPEG matching its MIME type and limits; diff --git a/src/models/valen/postprocess.py b/src/models/valen/postprocess.py index 08d0c6f7..c8b31974 100644 --- a/src/models/valen/postprocess.py +++ b/src/models/valen/postprocess.py @@ -23,28 +23,67 @@ def _probabilities(logits: Sequence[float], temperature: float) -> list[float]: return [value / total for value in exponentials] +def _choice_confidence(probabilities: Sequence[float]) -> float: + """Valen's original choice confidence formula (reference answer module).""" + + n = len(probabilities) + if n == 1: + return 1.0 + return max(0.0, (max(probabilities) - 1 / n) / (1 - 1 / n)) + + +def _noul_answer( + question: Question, probabilities: Sequence[float] +) -> dict[str, Any]: + """The reference's noul answer (P(true)) plus the full candidate pair.""" + + pair = dict(zip(question.keys, probabilities)) + return { + "type": "noul", + "noul": pair["true"], + "probabilities": pair, + "confidence": _choice_confidence(probabilities), + } + + +def _score_answer( + question: Question, probabilities: Sequence[float] +) -> dict[str, Any]: + """Valen's pinned score decoding: expected level and concentration.""" + + mode = max(range(len(probabilities)), key=probabilities.__getitem__) + count = len(probabilities) + distance = sum(p * abs(i - mode) for i, p in enumerate(probabilities)) + uniform_mad = sum(abs(i - (count - 1) / 2) for i in range(count)) / count + return { + "type": "score", + "probabilities": dict(zip(question.keys, probabilities)), + "score": sum(i * p for i, p in enumerate(probabilities)), + "legend": dict(zip(question.keys, question.descriptions)), + "confidence": max(0.0, 1.0 - distance / uniform_mad), + } + + def answer( question: Question, logits: Sequence[float], temperature: float = 1.0, ) -> dict[str, Any]: - """Match Valen's pinned choice decoding and confidence formula.""" + """Match Valen's pinned decoding and confidence formulas per type.""" if len(logits) != len(question.keys): raise ValueError("model returned one logit per candidate") probabilities = _probabilities(logits, temperature) + if question.kind == "noul": + return _noul_answer(question, probabilities) + if question.kind == "score": + return _score_answer(question, probabilities) mode = max(range(len(probabilities)), key=probabilities.__getitem__) - n = len(probabilities) - confidence = ( - 1.0 - if n == 1 - else max(0.0, (max(probabilities) - 1 / n) / (1 - 1 / n)) - ) return { "type": "choice", "choice": question.keys[mode], "probabilities": dict(zip(question.keys, probabilities)), - "confidence": confidence, + "confidence": _choice_confidence(probabilities), } diff --git a/src/models/valen/preprocess.py b/src/models/valen/preprocess.py index e0fec410..38e60363 100644 --- a/src/models/valen/preprocess.py +++ b/src/models/valen/preprocess.py @@ -13,7 +13,14 @@ from pathlib import Path from typing import Any -from .protocol import DEFAULT_SPECIAL_TOKENS, Question, Request, TextState +from .protocol import ( + DEFAULT_SPECIAL_TOKENS, + NOUL_FALSE_DESCRIPTION, + NOUL_TRUE_DESCRIPTION, + Question, + Request, + TextState, +) @dataclass(frozen=True) @@ -83,6 +90,47 @@ def compile(self, prepared: PreparedInput) -> CompiledInput: ) +def question_record(question: Question) -> dict[str, Any]: + """Build the pinned compiler's question record for one wire question. + + ``noul`` without custom wording compiles as the reference's native noul + record, whose candidate descriptions the pinned schema fixes. Custom + true/false wording cannot flow through a noul record (the pinned + ``candidates`` ignores noul criteria), so it compiles as a choice record + keyed ``true``/``false`` with the unspecified sides filled from the + reference's fixed wording — the same single-branch, two-candidate + structure. ``score`` passes its ordered level descriptions straight + through; the pinned schema assigns the level keys ``"0"``… + """ + + if question.kind == "noul": + descriptions = [ + description if description is not None else fixed + for description, fixed in zip( + question.descriptions, + (NOUL_TRUE_DESCRIPTION, NOUL_FALSE_DESCRIPTION), + ) + ] + if tuple(descriptions) == (NOUL_TRUE_DESCRIPTION, NOUL_FALSE_DESCRIPTION): + return {"type": "noul", "instructions": question.instructions} + return { + "type": "choice", + "instructions": question.instructions, + "criteria": dict(zip(question.keys, descriptions)), + } + if question.kind == "score": + return { + "type": "score", + "instructions": question.instructions, + "criteria": list(question.descriptions), + } + return { + "type": "choice", + "instructions": question.instructions, + "criteria": dict(zip(question.keys, question.descriptions)), + } + + def prepare_request(request: Request, media_root: Path) -> PreparedInput: """Materialize one validated request into the pinned Valen record format.""" @@ -122,12 +170,7 @@ def prepare_request(request: Request, media_root: Path) -> PreparedInput: } assets = [{"path": image_path.name, "sha256": image_sha256}] criteria = { - question.name: { - "type": "choice", - "instructions": question.instructions, - "criteria": dict(zip(question.keys, question.descriptions)), - } - for question in request.questions + question.name: question_record(question) for question in request.questions } record = { "request": {"state": state, "questions": criteria}, diff --git a/src/models/valen/protocol.py b/src/models/valen/protocol.py index 512b6190..7122104b 100644 --- a/src/models/valen/protocol.py +++ b/src/models/valen/protocol.py @@ -25,6 +25,17 @@ MAX_QUESTIONS = 8 MAX_OPTIONS = 255 MAX_TEXT = 16384 +MIN_SCORE_LEVELS = 2 +MAX_SCORE_LEVELS = 10 + +QUESTION_TYPES = ("choice", "noul", "score") + +# Verbatim from the pinned valen.data.schema.candidates noul wording at +# revision 750bfcfbb48a5275534a9c912257ebe83ca57a97; the compiler emits these +# descriptions for every noul question, so the worker reuses them whenever the +# wire leaves a side unspecified. +NOUL_TRUE_DESCRIPTION = "True / 是:满足问题中的条件。" +NOUL_FALSE_DESCRIPTION = "False / 否:不满足问题中的条件。" DEFAULT_SPECIAL_TOKENS = ( "<|image_pad|>", @@ -68,10 +79,19 @@ class TextState: @dataclass(frozen=True) class Question: + """One validated wire question. + + ``keys`` are the candidate names the pinned compiler assigns: wire option + keys for ``choice``, ``true``/``false`` for ``noul``, and the level + indices ``"0"``.. for ``score``. A ``None`` description marks a ``noul`` + side left to the reference's fixed wording. + """ + name: str instructions: str keys: tuple[str, ...] - descriptions: tuple[str, ...] + descriptions: tuple[str | None, ...] + kind: str = "choice" @dataclass(frozen=True) @@ -225,8 +245,11 @@ def parse_request(value: dict[str, Any], special_tokens=DEFAULT_SPECIAL_TOKENS) "criteria", }: raise RequestError(f"question {name!r} contains unsupported fields") - if question.get("type") != "choice": - raise RequestError(f"question {name!r}: only choice is supported") + kind = question.get("type") + if kind not in QUESTION_TYPES: + raise RequestError( + f"question {name!r}: type must be one of {', '.join(QUESTION_TYPES)}" + ) if "instructions" not in question: raise RequestError(f"question {name!r}: instructions is required") instructions = _text( @@ -234,34 +257,83 @@ def parse_request(value: dict[str, Any], special_tokens=DEFAULT_SPECIAL_TOKENS) ) if not instructions.strip(): raise RequestError(f"question {name!r}: instructions must be nonempty") - criteria = question.get("criteria") - if not isinstance(criteria, dict) or not 1 <= len(criteria) <= MAX_OPTIONS: - raise RequestError( - f"question {name!r}: choice requires 1 to {MAX_OPTIONS} options" - ) keys: list[str] = [] - descriptions: list[str] = [] - for key, label in criteria.items(): - if not isinstance(key, str) or not key.strip() or len(key) > 256: - raise RequestError("option keys must be nonempty and at most 256 characters") - if any(token in key for token in special_tokens): - # Candidate names enter the prompt, so keys get the same - # control-token guard as instructions and descriptions. + descriptions: list[str | None] = [] + if kind == "noul": + keys = ["true", "false"] + criteria = question.get("criteria") + if criteria is not None: + if not isinstance(criteria, dict) or not criteria: + raise RequestError( + f"question {name!r}: noul criteria must describe true and/or false" + ) + if set(criteria) - {"true", "false"}: + raise RequestError( + f"question {name!r}: noul criteria must describe true and/or false" + ) + for key in ("true", "false"): + label = (criteria or {}).get(key) + # None keeps the reference's fixed candidate wording; the + # preprocess layer substitutes it when building the record. + description = None + if label is not None: + description = _text( + label, f"question {name!r} criteria {key!r}", special_tokens + ) + if not description.strip(): + raise RequestError( + f"question {name!r}: criterion {key!r} must be nonempty" + ) + descriptions.append(description) + elif kind == "score": + criteria = question.get("criteria") + if ( + not isinstance(criteria, list) + or not MIN_SCORE_LEVELS <= len(criteria) <= MAX_SCORE_LEVELS + ): raise RequestError( - f"question {name!r}: option key {key!r} contains an" - " unsupported tokenizer control token" + f"question {name!r}: score requires an ordered list of" + f" {MIN_SCORE_LEVELS} to {MAX_SCORE_LEVELS} level descriptions" ) - keys.append(key) - description = ( - key - if label is None - else _text(label, f"question {name!r} criteria {key!r}", special_tokens) - ) - if not description.strip(): - raise RequestError(f"question {name!r}: criterion {key!r} must be nonempty") - descriptions.append(description) - if len(instructions) + sum(map(len, descriptions)) > MAX_TEXT: + keys = [str(index) for index in range(len(criteria))] + for index, label in enumerate(criteria): + description = _text( + label, f"question {name!r} level {index}", special_tokens + ) + if not description.strip(): + raise RequestError( + f"question {name!r}: level {index} must be nonempty" + ) + descriptions.append(description) + else: + criteria = question.get("criteria") + if not isinstance(criteria, dict) or not 1 <= len(criteria) <= MAX_OPTIONS: + raise RequestError( + f"question {name!r}: choice requires 1 to {MAX_OPTIONS} options" + ) + for key, label in criteria.items(): + if not isinstance(key, str) or not key.strip() or len(key) > 256: + raise RequestError("option keys must be nonempty and at most 256 characters") + if any(token in key for token in special_tokens): + # Candidate names enter the prompt, so keys get the same + # control-token guard as instructions and descriptions. + raise RequestError( + f"question {name!r}: option key {key!r} contains an" + " unsupported tokenizer control token" + ) + keys.append(key) + description = ( + key + if label is None + else _text(label, f"question {name!r} criteria {key!r}", special_tokens) + ) + if not description.strip(): + raise RequestError(f"question {name!r}: criterion {key!r} must be nonempty") + descriptions.append(description) + if len(instructions) + sum(len(d) for d in descriptions if d is not None) > MAX_TEXT: raise RequestError("combined question text exceeds 16384 characters") - parsed.append(Question(name, instructions, tuple(keys), tuple(descriptions))) + parsed.append( + Question(name, instructions, tuple(keys), tuple(descriptions), kind) + ) return Request(MODEL_NAME, _state(value["state"], special_tokens), tuple(parsed)) diff --git a/tests/valen/test_postprocess.py b/tests/valen/test_postprocess.py index 837d420e..40dcc54d 100644 --- a/tests/valen/test_postprocess.py +++ b/tests/valen/test_postprocess.py @@ -48,3 +48,83 @@ def test_build_response_preserves_question_order_and_usage(): "usage": {"input_tokens": 17, "output_tokens": 0}, "internal_usage": {"compute_tokens": 34}, } + + +NOUL = Question("ask", "Was it duplicated?", ("true", "false"), (None, None), "noul") + + +def test_noul_answer_reports_true_probability_and_full_pair(): + result = answer(NOUL, [0.0, math.log(3.0)]) + + assert result["type"] == "noul" + assert result["noul"] == pytest.approx(0.25) + assert result["probabilities"] == { + "true": pytest.approx(0.25), + "false": pytest.approx(0.75), + } + # Valen's choice confidence formula evaluated on the two candidates. + assert result["confidence"] == pytest.approx(0.5) + + flipped = answer(NOUL, [math.log(3.0), 0.0]) + assert flipped["noul"] == pytest.approx(0.75) + + +def test_noul_answer_honors_temperature_and_rejects_bad_logits(): + sharp = answer(NOUL, [1.0, 0.0], temperature=0.5) + plain = answer(NOUL, [1.0, 0.0], temperature=1.0) + assert sharp["noul"] > plain["noul"] > 0.5 + + with pytest.raises(ValueError, match="one logit"): + answer(NOUL, [0.0]) + with pytest.raises(ValueError, match="invalid logits"): + answer(NOUL, [float("nan"), 0.0]) + + +SCORE = Question( + "grade", + "Grade the severity.", + ("0", "1", "2"), + ("No error", "A minor error", "A major error"), + "score", +) + + +def test_score_answer_reports_expected_level_and_distribution(): + result = answer(SCORE, [0.0, 0.0, 0.0]) + + assert result["type"] == "score" + assert result["probabilities"] == {"0": pytest.approx(1 / 3), "1": pytest.approx(1 / 3), "2": pytest.approx(1 / 3)} + assert result["legend"] == {"0": "No error", "1": "A minor error", "2": "A major error"} + assert result["score"] == pytest.approx(1.0) + # A uniform distribution has no concentration. + assert result["confidence"] == pytest.approx(0.0) + + peaked = answer(SCORE, [-100.0, 3.0, -100.0]) + assert peaked["score"] == pytest.approx(1.0) + assert peaked["confidence"] == pytest.approx(1.0) + + +def test_score_answer_confidence_penalizes_split_mass(): + # Mass split between the two endpoints: mode is 0 (ties go to the + # earliest level), distance = 0.6 * 0 + 0.4 * 2 = 0.8 against a uniform + # mean absolute deviation of 2/3, so confidence is 0 (clamped). + result = answer(SCORE, [math.log(1.5), -50.0, 0.0]) + assert result["probabilities"]["0"] == pytest.approx(0.6) + assert result["probabilities"]["2"] == pytest.approx(0.4, abs=1e-6) + assert result["score"] == pytest.approx(0.8) + assert result["confidence"] == 0.0 + + # A tilted but concentrated distribution: p = [0.7, 0.2, 0.1] has mode 0, + # distance 0.2*1 + 0.1*2 = 0.4 and uniform MAD 2/3, so confidence is + # 1 - 0.4/(2/3) = 0.4. + tilted = answer(SCORE, [math.log(7.0), math.log(2.0), 0.0]) + assert tilted["probabilities"]["0"] == pytest.approx(0.7) + assert tilted["score"] == pytest.approx(0.4) + assert tilted["confidence"] == pytest.approx(0.4) + + +def test_score_answer_rejects_wrong_logit_counts(): + with pytest.raises(ValueError, match="one logit"): + answer(SCORE, [0.0, 1.0]) + with pytest.raises(RequestError, match="temperature"): + answer(SCORE, [0.0, 1.0, 2.0], temperature=-1.0) diff --git a/tests/valen/test_preprocess.py b/tests/valen/test_preprocess.py index 5f8280dc..accbb65b 100644 --- a/tests/valen/test_preprocess.py +++ b/tests/valen/test_preprocess.py @@ -1,7 +1,12 @@ import hashlib -from models.valen.preprocess import prepare_request -from models.valen.protocol import MODEL_NAME, parse_request +from models.valen.preprocess import prepare_request, question_record +from models.valen.protocol import ( + MODEL_NAME, + NOUL_FALSE_DESCRIPTION, + NOUL_TRUE_DESCRIPTION, + parse_request, +) from .test_protocol import body @@ -52,3 +57,113 @@ def test_prepare_compiles_text_state_without_media(tmp_path): } assert list(prepared.record["request"]["questions"]) == ["move"] assert list(tmp_path.iterdir()) == [] + + +def noul_question(criteria=None): + question = {"type": "noul", "instructions": "Was the card charged twice?"} + if criteria is not None: + question["criteria"] = criteria + request = parse_request( + body(state="The card was charged twice.", questions={"ask": question}) + ) + return request.questions[0] + + +def test_question_record_keeps_reference_noul_wording_native(): + record = question_record(noul_question()) + + assert record == {"type": "noul", "instructions": "Was the card charged twice?"} + + explicit = question_record( + noul_question( + criteria={ + "true": NOUL_TRUE_DESCRIPTION, + "false": NOUL_FALSE_DESCRIPTION, + } + ) + ) + assert explicit == record + + +def test_question_record_carries_custom_noul_wording_as_choice(): + record = question_record( + noul_question(criteria={"true": "It was duplicated.", "false": "It was unique."}) + ) + + assert record == { + "type": "choice", + "instructions": "Was the card charged twice?", + "criteria": {"true": "It was duplicated.", "false": "It was unique."}, + } + + partial = question_record(noul_question(criteria={"false": "It was unique."})) + assert partial["criteria"] == { + "true": NOUL_TRUE_DESCRIPTION, + "false": "It was unique.", + } + + +def test_question_record_passes_score_levels_in_order(): + request = parse_request( + body( + state="Dialog: the update finished.", + questions={ + "grade": { + "type": "score", + "instructions": "Grade the dialog.", + "criteria": ["unclear", "clear"], + } + }, + ) + ) + + assert question_record(request.questions[0]) == { + "type": "score", + "instructions": "Grade the dialog.", + "criteria": ["unclear", "clear"], + } + + +def test_prepare_builds_mixed_type_records(tmp_path): + request = parse_request( + body( + state="The card was charged twice for one order.", + questions={ + "ask": { + "type": "noul", + "instructions": "Was the card charged twice?", + }, + "grade": { + "type": "score", + "instructions": "Grade the severity.", + "criteria": ["No error", "A minor error", "A major error"], + }, + "act": { + "type": "choice", + "instructions": "Decide the refund action.", + "criteria": {"refund": None, "wait": "Wait for review"}, + }, + }, + ) + ) + prepared = prepare_request(request, tmp_path) + + assert prepared.record["request"]["questions"] == { + "ask": {"type": "noul", "instructions": "Was the card charged twice?"}, + "grade": { + "type": "score", + "instructions": "Grade the severity.", + "criteria": ["No error", "A minor error", "A major error"], + }, + "act": { + "type": "choice", + "instructions": "Decide the refund action.", + "criteria": {"refund": "refund", "wait": "Wait for review"}, + }, + } + assert prepared.record["assets"] == [] + assert [q.kind for q in prepared.response_context.questions] == [ + "noul", + "score", + "choice", + ] diff --git a/tests/valen/test_protocol.py b/tests/valen/test_protocol.py index 3f073fd2..940c5a09 100644 --- a/tests/valen/test_protocol.py +++ b/tests/valen/test_protocol.py @@ -76,14 +76,19 @@ def test_parse_rejects_wrong_model_and_invalid_state(): parse_request(body(state=17)) -def test_parse_allows_structured_text_but_not_score_or_extra_fields(): +def test_parse_allows_structured_text_but_rejects_unknown_types_and_fields(): value = body() value["questions"]["move"]["instructions"] = {"goal": "choose"} assert parse_request(value).questions[0].instructions == '{"goal": "choose"}' value = body() - value["questions"]["move"]["type"] = "score" - with pytest.raises(RequestError, match="only choice"): + value["questions"]["move"]["type"] = "rank" + with pytest.raises(RequestError, match="type must be one of choice, noul, score"): + parse_request(value) + + value = body() + del value["questions"]["move"]["type"] + with pytest.raises(RequestError, match="type must be one of"): parse_request(value) value = body() @@ -158,3 +163,129 @@ def test_parse_rejects_media_control_tokens(): with pytest.raises(RequestError, match="control token"): parse_request(body(state="context <|video_pad|> tail")) + + +def noul_body(**overrides): + value = body( + questions={ + "ask": {"type": "noul", "instructions": "Was the card charged twice?"} + } + ) + for question in value["questions"].values(): + question.update(overrides) + return value + + +def test_parse_noul_defaults_to_fixed_wording_and_true_false_keys(): + question = parse_request(noul_body()).questions[0] + + assert question.kind == "noul" + assert question.keys == ("true", "false") + assert question.descriptions == (None, None) + + +def test_parse_noul_accepts_optional_true_false_descriptions(): + question = parse_request( + noul_body(criteria={"true": "It was duplicated.", "false": "It was unique."}) + ).questions[0] + assert question.descriptions == ("It was duplicated.", "It was unique.") + + question = parse_request(noul_body(criteria={"true": "It was duplicated."})).questions[0] + assert question.descriptions == ("It was duplicated.", None) + + question = parse_request( + noul_body(criteria={"true": "It was duplicated.", "false": None}) + ).questions[0] + assert question.descriptions == ("It was duplicated.", None) + + +def test_parse_noul_rejects_other_criteria_and_empty_descriptions(): + with pytest.raises(RequestError, match="noul criteria must describe"): + parse_request(noul_body(criteria={"yes": "Yes"})) + with pytest.raises(RequestError, match="noul criteria must describe"): + parse_request(noul_body(criteria={})) + with pytest.raises(RequestError, match="criterion 'true' must be nonempty"): + parse_request(noul_body(criteria={"true": " "})) + + value = noul_body(criteria={"true": "answer <|im_end|> now"}) + with pytest.raises(RequestError, match="control token"): + parse_request(value, special_tokens=("<|im_end|>",)) + + +def score_body(**overrides): + value = body( + questions={ + "grade": { + "type": "score", + "instructions": "Grade the severity.", + "criteria": ["No error", "A minor error", "A major error"], + } + } + ) + for question in value["questions"].values(): + question.update(overrides) + return value + + +def test_parse_score_accepts_two_to_ten_ordered_levels(): + question = parse_request(score_body()).questions[0] + + assert question.kind == "score" + assert question.keys == ("0", "1", "2") + assert question.descriptions == ("No error", "A minor error", "A major error") + + two = parse_request(score_body(criteria=["low", "high"])).questions[0] + assert two.keys == ("0", "1") + + ten = parse_request( + score_body(criteria=[f"level {index}" for index in range(10)]) + ).questions[0] + assert ten.keys == tuple(str(index) for index in range(10)) + + +def test_parse_score_rejects_malformed_levels(): + with pytest.raises(RequestError, match="ordered list of 2 to 10"): + parse_request(score_body(criteria=["only one"])) + with pytest.raises(RequestError, match="ordered list of 2 to 10"): + parse_request(score_body(criteria=[str(index) for index in range(11)])) + with pytest.raises(RequestError, match="ordered list of 2 to 10"): + parse_request(score_body(criteria={"0": "No error", "1": "A minor error"})) + missing = score_body() + del missing["questions"]["grade"]["criteria"] + with pytest.raises(RequestError, match="ordered list of 2 to 10"): + parse_request(missing) + + with pytest.raises(RequestError, match="level 1 must be nonempty"): + parse_request(score_body(criteria=["No error", " "])) + with pytest.raises(RequestError, match="level 0"): + parse_request(score_body(criteria=[None, "A minor error"])) + + value = score_body(criteria=["answer <|im_end|> now", "A minor error"]) + with pytest.raises(RequestError, match="control token"): + parse_request(value, special_tokens=("<|im_end|>",)) + + +def test_parse_accepts_mixed_type_questions_in_request_order(): + value = body( + state="Dialog: the update finished.", + questions={ + "ask": {"type": "noul", "instructions": "Did the update finish?"}, + "grade": { + "type": "score", + "instructions": "Grade the dialog.", + "criteria": ["unclear", "clear"], + }, + "act": { + "type": "choice", + "instructions": "Choose a move.", + "criteria": {"up": "Move up", "down": "Move down"}, + }, + }, + ) + request = parse_request(value) + + assert [(question.kind, question.name) for question in request.questions] == [ + ("noul", "ask"), + ("score", "grade"), + ("choice", "act"), + ] diff --git a/tests/valen/test_worker.py b/tests/valen/test_worker.py index fec9cf6e..064e8400 100644 --- a/tests/valen/test_worker.py +++ b/tests/valen/test_worker.py @@ -1,4 +1,5 @@ import json +import math import pytest @@ -36,6 +37,9 @@ def execute(self, compiled): assert compiled.response_context.questions return self.output + def close(self): + pass + def test_decide_keeps_http_adapter_outside_executor(): processor = FakeProcessor() @@ -96,3 +100,53 @@ def compile(self, prepared): assert excinfo.value.status == 422 assert str(excinfo.value) == "wire problem" + +def test_decide_answers_mixed_type_questions_from_one_state(): + value = body( + state="The card was charged twice for one order.", + questions={ + "ask": {"type": "noul", "instructions": "Was the card charged twice?"}, + "grade": { + "type": "score", + "instructions": "Grade the severity.", + "criteria": ["No error", "A minor error", "A major error"], + }, + "act": { + "type": "choice", + "instructions": "Decide the refund action.", + "criteria": {"refund": None, "wait": "Wait for review"}, + }, + }, + ) + processor = FakeProcessor() + executor = FakeExecutor( + ExecutorOutput( + ( + (math.log(3.0), 0.0), # noul: true, false + (math.log(7.0), math.log(2.0), 0.0), # score levels 0, 1, 2 + (0.0, 1.0), # choice: refund, wait + ), + 61, + 183, + ) + ) + + result = decide(json.dumps(value).encode("utf-8"), processor, executor) + + noul = result["answers"]["ask"] + assert noul["type"] == "noul" + assert noul["noul"] == pytest.approx(0.75) + assert noul["probabilities"]["true"] == pytest.approx(0.75) + assert noul["probabilities"]["false"] == pytest.approx(0.25) + + score = result["answers"]["grade"] + assert score["type"] == "score" + assert score["probabilities"]["0"] == pytest.approx(0.7) + assert score["score"] == pytest.approx(0.4) + assert score["legend"] == {"0": "No error", "1": "A minor error", "2": "A major error"} + assert score["confidence"] == pytest.approx(0.4) + + assert result["answers"]["act"]["choice"] == "wait" + assert result["usage"] == {"input_tokens": 61, "output_tokens": 0} + assert result["internal_usage"] == {"compute_tokens": 183} + diff --git a/tests/valen/test_worker_server.py b/tests/valen/test_worker_server.py index 66615a11..c71e9313 100644 --- a/tests/valen/test_worker_server.py +++ b/tests/valen/test_worker_server.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import math import threading from http.client import HTTPConnection @@ -164,6 +165,43 @@ def test_client_errors_map_to_their_status_and_detail(server): assert status == 422 and "nonempty" in json.loads(raw)["detail"] +def test_noul_and_score_questions_are_served_over_http(): + executor = FakeExecutor( + ExecutorOutput(((0.0, 1.0), (math.log(7.0), math.log(2.0), 0.0)), 5, 11) + ) + mixed = start(executor) + try: + value = body( + state="The card was charged twice for one order.", + questions={ + "ask": {"type": "noul", "instructions": "Was the card charged twice?"}, + "grade": { + "type": "score", + "instructions": "Grade the severity.", + "criteria": ["No error", "A minor error", "A major error"], + }, + }, + ) + status, _, raw = post(mixed, json.dumps(value).encode("utf-8")) + assert status == 200 + result = json.loads(raw) + assert result["answers"]["ask"]["type"] == "noul" + assert result["answers"]["grade"]["type"] == "score" + assert result["internal_usage"] == {"compute_tokens": 11} + finally: + stop(mixed) + + +def test_invalid_score_levels_map_to_422(server): + value = body( + questions={ + "grade": {"type": "score", "instructions": "Grade.", "criteria": ["only"]} + } + ) + status, _, raw = post(server, json.dumps(value).encode("utf-8")) + assert status == 422 and "2 to 10" in json.loads(raw)["detail"] + + def test_busy_worker_returns_503(server): server.inference_lock.acquire() try: From cb78ab95178a07b1e5cdab2be300fe107e05208b Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Wed, 14 Oct 2026 00:20:16 +0800 Subject: [PATCH 02/32] valen: state the logit-count requirement in the executor error The cardinality check's message read like the satisfied condition; name the requirement so the 500-side error is actionable. (cherry picked from commit 3afa240b5e61dd95c9a826bc3a51a914ed2de866) --- src/models/valen/postprocess.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/valen/postprocess.py b/src/models/valen/postprocess.py index c8b31974..c739d0d5 100644 --- a/src/models/valen/postprocess.py +++ b/src/models/valen/postprocess.py @@ -72,7 +72,7 @@ def answer( """Match Valen's pinned decoding and confidence formulas per type.""" if len(logits) != len(question.keys): - raise ValueError("model returned one logit per candidate") + raise ValueError("model must return one logit per candidate") probabilities = _probabilities(logits, temperature) if question.kind == "noul": return _noul_answer(question, probabilities) From 2192c27ffc2490d3faa727f3e9e418ee6791f14f Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Wed, 14 Oct 2026 00:26:46 +0800 Subject: [PATCH 03/32] docs(valen): pin the parity records to the committed noul/score tree The noul/score blocks were originally recorded from a working tree, and the rebase onto current upstream main (the reference slice landed as #115) orphaned the earlier verification SHAs. All four parity blocks and the frontend byte-identity were re-verified verbatim at 3afa240; the records now reference that tree. (cherry picked from commit 28072dcd6615bc163fb9d0874230dd6ea76b5c8f) --- recipe/valen/reference.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/recipe/valen/reference.md b/recipe/valen/reference.md index e0263b18..88501b0a 100644 --- a/recipe/valen/reference.md +++ b/recipe/valen/reference.md @@ -101,7 +101,7 @@ Pillow: 12.3.0 av: 16.1.0 GPU capability: sm_120 dtype: bfloat16 -system1-omni: 2fd8ce9 (GPU serving and smoke records); parity blocks re-verified verbatim at c9fb2d2; noul/score records collected on the same host from the spec-01 working tree (branch model/valen-reference-serving, uncommitted) +system1-omni: serving and smoke records collected during the reference slice (upstream #115); all four parity blocks and the frontend byte-identity re-verified verbatim at 3afa240 on the same host ~~~ FP32 and BF16 small-operator checks passed. Startup memory was approximately @@ -363,8 +363,8 @@ cmp /tmp/valen-worker.json /tmp/valen-frontend.json The validated runs returned 200 from both endpoints with matching JSON content types and byte-identical response bodies, for the image fixture above and for the text-only example. The noul and score fixtures were re-checked the same -way on the same host from the spec-01 working tree: byte-identical through -the frontend for all three fixtures. +way on the same host at 3afa240: byte-identical through the frontend for all +three fixtures. ## Error behavior From 0abc00c6131e75e5d3ee85635d3dcfeaf1b6f663 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Wed, 14 Oct 2026 23:12:31 +0800 Subject: [PATCH 04/32] docs(valen): keep the recipe to procedure and move new run records to the PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the noul/score documentation with the d3 recipe convention: the recipe records the procedure, commands and fixtures, while measured run output — the observed responses, the verbatim noul/score parity blocks and their provenance SHAs — lives in the pull request description. The model README drops its duplicate request examples and states the compiler record rules and answer fields in compact bullets. Only content this branch added or rewrote changes; the #115 slice's records stay as they were. --- recipe/valen/reference.md | 130 +++++++++---------------------------- src/models/valen/README.md | 73 +++++---------------- 2 files changed, 47 insertions(+), 156 deletions(-) diff --git a/recipe/valen/reference.md b/recipe/valen/reference.md index 88501b0a..09cfb240 100644 --- a/recipe/valen/reference.md +++ b/recipe/valen/reference.md @@ -101,7 +101,7 @@ Pillow: 12.3.0 av: 16.1.0 GPU capability: sm_120 dtype: bfloat16 -system1-omni: serving and smoke records collected during the reference slice (upstream #115); all four parity blocks and the frontend byte-identity re-verified verbatim at 3afa240 on the same host +system1-omni: 2fd8ce9 (GPU serving and smoke records); parity blocks re-verified verbatim at c9fb2d2 ~~~ FP32 and BF16 small-operator checks passed. Startup memory was approximately @@ -263,35 +263,21 @@ curl -sS http://127.0.0.1:8000/v1/systemone \ --data-binary @recipe/valen/example-noul-request.json ~~~ -Observed response, same host as the fixtures above: +Expected response shape (the observed response for this fixture is recorded +in the integration pull request): ~~~json { "model": "valen-preview-0923", "answers": { - "duplicate": { - "type": "noul", - "noul": 0.19441015095399058, - "probabilities": {"true": 0.19441015095399058, "false": 0.8055898490460095}, - "confidence": 0.611179698092019 - }, - "authorized": { - "type": "noul", - "noul": 0.3848572797166159, - "probabilities": {"true": 0.3848572797166159, "false": 0.6151427202833842}, - "confidence": 0.23028544056676847 - } + "duplicate": {"type": "noul", "noul": 0.0, "probabilities": {"true": 0.0, "false": 0.0}, "confidence": 0.0}, + "authorized": {"type": "noul", "noul": 0.0, "probabilities": {"true": 0.0, "false": 0.0}, "confidence": 0.0} }, - "usage": {"input_tokens": 109, "output_tokens": 0}, - "internal_usage": {"compute_tokens": 123} + "usage": {"input_tokens": 0, "output_tokens": 0}, + "internal_usage": {"compute_tokens": 0} } ~~~ -`noul` is `P(true)` — the reference's own answer field — and the full -two-candidate pair is exposed alongside it. The custom-wording question -compiles through the equivalent `true`/`false` choice record, so its branch -adds its own suffix to `internal_usage.compute_tokens`. - A graded assessment uses `score` with an ordered list of 2 to 10 level descriptions. The complete runnable request is in [`example-score-request.json`](example-score-request.json): @@ -302,33 +288,24 @@ curl -sS http://127.0.0.1:8000/v1/systemone \ --data-binary @recipe/valen/example-score-request.json ~~~ -Observed response, same host: +Expected response shape (the observed response for this fixture is recorded +in the integration pull request): ~~~json { "model": "valen-preview-0923", "answers": {"severity": { "type": "score", - "probabilities": {"0": 0.17458891628733877, "1": 0.6222512480902359, "2": 0.2031598356224252}, - "score": 1.0285709193350863, - "legend": { - "0": "No billing error: every charge is correct.", - "1": "Minor error: a single duplicated charge.", - "2": "Major error: repeated duplicated charges." - }, - "confidence": 0.43337687213535403 + "probabilities": {"0": 0.0, "1": 0.0, "2": 0.0}, + "score": 0.0, + "legend": {"0": "level 0 text", "1": "level 1 text", "2": "level 2 text"}, + "confidence": 0.0 }}, - "usage": {"input_tokens": 107, "output_tokens": 0}, - "internal_usage": {"compute_tokens": 135} + "usage": {"input_tokens": 0, "output_tokens": 0}, + "internal_usage": {"compute_tokens": 0} } ~~~ -Each score level compiles to its own isolated branch (it never sees the other -levels or its own level ID), the K per-level decision logits are softmaxed -across branches, and `score` reports the expected level with the reference's -distribution-concentration `confidence`. The three branches explain -`compute_tokens` 135 against `input_tokens` 107. - ## Start and verify the Rust frontend ~~~sh @@ -346,9 +323,8 @@ curl -sS http://127.0.0.1:8080/v1/systemone \ --data-binary @recipe/valen/example-request.json ~~~ -The generic `recipe/compare_with_backend.py` also sends `score`/`noul` cases, -which this worker now answers; the byte-identical comparison below uses the -Valen fixture directly: +The generic `recipe/compare_with_backend.py` also covers `score`/`noul` +cases; the byte-identical comparison below uses the Valen fixture directly: ~~~sh curl -fsS http://127.0.0.1:8000/v1/systemone \ @@ -363,8 +339,8 @@ cmp /tmp/valen-worker.json /tmp/valen-frontend.json The validated runs returned 200 from both endpoints with matching JSON content types and byte-identical response bodies, for the image fixture above and for the text-only example. The noul and score fixtures were re-checked the same -way on the same host at 3afa240: byte-identical through the frontend for all -three fixtures. +way on the same host: byte-identical through the frontend for all three +fixtures. ## Error behavior @@ -436,65 +412,18 @@ probability tolerance <= 1e-06: PASS token accounting: PASS ~~~ -The `noul` case covers both wordings in -[`example-noul-request.json`](example-noul-request.json) — the fixed-wording -question compiles as the reference's native noul record, the custom-wording -question as the equivalent `true`/`false` choice record — against the pinned -reference decoder: - -~~~text -case: noul -reference model: Valen -worker model: valen-preview-0923 -input_tokens: 109 == 109 -output_tokens: 0 == 0 -compute_tokens: 123 == 123 -max probability delta: 4.7029371064e-08 -max answer delta: 1.2620447742e-08 -confidence delta: 2.5240895818e-08 -decision agreement: PASS -probability tolerance <= 1e-06: PASS -answer tolerance <= 1e-06: PASS -token accounting: PASS -~~~ - -The `score` case compares the three-level fixture in -[`example-score-request.json`](example-score-request.json): per-branch -isolation, the cross-branch softmax, the expected level, the legend and the -distribution-concentration confidence: - -~~~text -case: score -reference model: Valen -worker model: valen-preview-0923 -input_tokens: 107 == 107 -output_tokens: 0 == 0 -compute_tokens: 135 == 135 -max probability delta: 2.4111302177e-08 -max answer delta: 1.4517812730e-08 -confidence delta: 5.5582444203e-09 -decision agreement: PASS -probability tolerance <= 1e-06: PASS -answer tolerance <= 1e-06: PASS -legend agreement: PASS -token accounting: PASS -~~~ - The reference model field is `Valen`; the system1-omni worker deliberately uses `valen-preview-0923` as its stable serving identity. -Reproduce with [`compare_reference.py`](compare_reference.py). The script loads -one pinned model instance and drives each request through two paths: a -Valen-native record compiled by the pinned `Compiler` — built by the worker's -own record rules so the reference input cannot drift from the served one — -and the production `frontend.valen.decide` pipeline. `noul` and `score` -answers are additionally decoded by the pinned reference answer module -(`valen.evaluation.inference.answer`). The decision criteria are exact -discrete agreement (candidate order, the chosen key, the score legend), equal -token accounting and the declared tolerance (default `1e-6`, `--tolerance` to -override) on probabilities and on the scalar answer fields (`noul` P(true), -`score` expected level), which the worker's pure-Python softmax and the -reference's torch softmax never match bit-exactly: +Reproduce with [`compare_reference.py`](compare_reference.py). Both paths +share one pinned model instance: a Valen-native record compiled by the pinned +`Compiler` from the worker's own record rules, against the production +`frontend.valen.decide` pipeline; `noul` and `score` answers are decoded by +the pinned reference answer module (`valen.evaluation.inference.answer`). The +criteria are exact discrete agreement (candidate order, chosen key, score +legend), equal token accounting, and the declared tolerance (default `1e-6`, +`--tolerance` to override) on probabilities and the scalar answer fields +(`noul` P(true), `score` expected level): ~~~sh PYTHONPATH=src .venv/bin/python recipe/valen/compare_reference.py \ @@ -519,7 +448,8 @@ case uses the recipe's text example unless `--text-body` points elsewhere. The `noul` and `score` cases use [`example-noul-request.json`](example-noul-request.json) and [`example-score-request.json`](example-score-request.json) unless `--noul-body`/`--score-body` point elsewhere; `--case all` runs all four in -one model load. All blocks above are verbatim output of the committed tool. +one model load. Both blocks above are verbatim output of the committed tool; +the `noul` and `score` blocks are recorded in the integration pull request. ## Troubleshooting and limits diff --git a/src/models/valen/README.md b/src/models/valen/README.md index 30053395..d94585ed 100644 --- a/src/models/valen/README.md +++ b/src/models/valen/README.md @@ -72,58 +72,22 @@ A text-only request replaces the image object with plain text: } ~~~ -A yes/no judgment uses `noul`: - -~~~json -{ - "model": "valen-preview-0923", - "state": "The card was charged twice for one order.", - "questions": { - "duplicate": { - "type": "noul", - "instructions": "Was the card charged twice for one order?" - } - } -} -~~~ - -A graded assessment uses `score` with an ordered level list: - -~~~json -{ - "model": "valen-preview-0923", - "state": "The card was charged twice for one order.", - "questions": { - "severity": { - "type": "score", - "instructions": "How severe is the billing error?", - "criteria": [ - "No billing error: every charge is correct.", - "Minor error: a single duplicated charge.", - "Major error: repeated duplicated charges." - ] - } - } -} -~~~ - Preprocessing validates and decodes the data URL, writes it to a request-scoped directory, hashes the bytes, and converts it to Valen's local-media `messages` record. Text state skips materialization and compiles a text-only user message with no assets. The worker owns that directory's lifetime. The pinned compiler builds every question type from the same record family: -`choice` and `noul` compile to one branch holding all candidates, while -`score` compiles one isolated branch per level — each branch sees only its own -level description, never the level ID or the other levels — and the K -per-level decision logits are softmaxed across branches. A `noul` question -without custom wording compiles as the reference's native noul record with -the pinned schema's fixed candidate wording. Custom true/false wording cannot -flow through a noul record (the pinned `candidates` ignores noul criteria), so -it compiles as an equivalent `choice` record keyed `true`/`false` with -unspecified sides filled from the same fixed wording: the same one-branch, -two-candidate structure, so `P(true)` still comes from the two-candidate -softmax. + +- `choice` and `noul` compile to one branch holding all candidates; +- `score` compiles one isolated branch per level — each branch sees only its + own level description, never the level ID or the other levels — and the K + per-level decision logits are softmaxed across branches; +- `noul` without custom wording is the reference's native noul record with + the schema's fixed candidate wording; custom wording cannot flow through + it, so it compiles as the equivalent `choice` record keyed `true`/`false`, + with unspecified sides filled from the same fixed wording — `P(true)` still + comes from the two-candidate softmax. Postprocessing keeps Valen's original formulas for this reference slice: @@ -148,18 +112,15 @@ highest probability, ties go to the earliest candidate in request order, and `probabilities` maps every candidate key to its probability in request order. Each `noul` answer is `{"type": "noul", "noul": ..., -"probabilities": {"true": ..., "false": ...}, "confidence": ...}`: `noul` is -`P(true)` — the field name matches Valen's reference answer module — and the -full two-candidate pair is exposed so clients can derive their own -quantities. The reference's own noul answer carries only `P(true)`; the pair -and confidence are this worker's documented extension. +"probabilities": ..., "confidence": ...}`: `noul` is `P(true)`, the reference +answer module's own field; the pair and confidence are this worker's +extension (the reference answer carries only `P(true)`). Each `score` answer is `{"type": "score", "probabilities": ..., -"score": ..., "legend": ..., "confidence": ...}` with the reference's field -names: `probabilities` maps the level keys `"0"`… to their cross-branch -softmax probability, `score` is the expected level `sum(i * p_i)`, and -`legend` maps each level key back to its request-order description. Values -are emitted unrounded, like every other answer field. +"score": ..., "legend": ..., "confidence": ...}`: `probabilities` maps the +level keys `"0"`… to their cross-branch softmax probability, `score` is the +expected level `sum(i * p_i)`, and `legend` maps each level key back to its +request-order description. The response `model` is `valen-preview-0923`, the worker's stable serving identity. `usage.input_tokens` counts the compiled prompt's logical tokens: From 507c44865c99972481ba842f4ba382e1b071b59c Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 00:00:54 +0800 Subject: [PATCH 05/32] valen: gate parity on the requested answer type and the confidence delta Address the review findings on compare_reference: dispatch the per-type comparison on the type requested in the wire body (rejecting answers that drift to another shape instead of silently skipping the noul/score checks), and include the confidence delta in the pass criteria with its own tolerance verdict line. --- recipe/valen/compare_reference.py | 70 ++++++++++++++++++------ recipe/valen/test_compare_reference.py | 73 +++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 17 deletions(-) diff --git a/recipe/valen/compare_reference.py b/recipe/valen/compare_reference.py index 43f0d1c3..618b8298 100644 --- a/recipe/valen/compare_reference.py +++ b/recipe/valen/compare_reference.py @@ -14,12 +14,13 @@ Both paths share one loaded model instance, so the comparison isolates input construction, candidate ordering, decision-head readout and probability normalization instead of checkpoint loading. The declared criteria are exact -discrete agreement (candidate order, the chosen key, the score legend), equal -token accounting and a tolerance of 1e-6 (``--tolerance``) on probabilities -and on the scalar answer fields (``noul`` P(true), ``score`` expected level), -which the two softmax implementations never match bit-exactly. Requires the -pinned Valen source, the Preview checkpoint, the Qwen3.5-2B base and a -working torch device; see recipe/valen/reference.md. +discrete agreement (candidate order, the requested answer type, the chosen +key, the score legend), equal token accounting and a tolerance of 1e-6 +(``--tolerance``) on probabilities, confidences and the scalar answer fields +(``noul`` P(true), ``score`` expected level), which the two softmax +implementations never match bit-exactly. Requires the pinned Valen source, +the Preview checkpoint, the Qwen3.5-2B base and a working torch device; see +recipe/valen/reference.md. """ from __future__ import annotations @@ -115,6 +116,23 @@ def reference_questions(body, special_tokens=None): return {question.name: question_record(question) for question in request.questions} +def expected_answer_types(body, special_tokens=None): + """The requested public answer type per question, from the wire body. + + The worker's public ``type`` field mirrors the request's question type, + so parity dispatches on the requested type: an answer that drifts to + another shape (for example a ``noul`` answered as a plain choice) fails + the comparison instead of silently skipping the noul/score checks. + """ + + from models.valen.protocol import DEFAULT_SPECIAL_TOKENS, parse_request + + request = parse_request( + body, special_tokens=special_tokens or DEFAULT_SPECIAL_TOKENS + ) + return {question.name: question.kind for question in request.questions} + + def reference_image_record(image_bytes, image_format, questions, media_dir): """A Valen-native image record with its own materialized media copy.""" @@ -158,13 +176,18 @@ def reference_confidence(probabilities): return max(0.0, (max(probabilities) - 1 / count) / (1 - 1 / count)) -def compare_case(name, worker_response, reference, tolerance=PROBABILITY_TOLERANCE): +def compare_case( + name, worker_response, reference, expected_types, tolerance=PROBABILITY_TOLERANCE +): """Compare one worker response against one reference result. - ``choice`` answers must agree on the chosen key; ``noul`` answers on - P(true); ``score`` answers on the expected level and the level legend. - All scalar comparisons use the declared tolerance because the worker and - the pinned reference normalize probabilities through different softmax + ``expected_types`` maps each question id to its requested public answer + type; every answer must keep that type before any per-type comparison + runs. ``choice`` answers must agree on the chosen key; ``noul`` answers + on P(true); ``score`` answers on the expected level and the level legend. + All scalar comparisons — probabilities, confidences and the scalar answer + fields — use the declared tolerance because the worker and the pinned + reference normalize probabilities through different softmax implementations. Returns (report lines, passed). """ @@ -179,6 +202,13 @@ def compare_case(name, worker_response, reference, tolerance=PROBABILITY_TOLERAN scalar_answers = False for qid, ref_keys in reference["keys"].items(): answer = worker_response["answers"][qid] + expected_type = expected_types[qid] + if answer["type"] != expected_type: + problems.append( + f"{qid}: answer type {answer['type']!r} != requested {expected_type!r}" + ) + decision_exact = False + continue worker_keys = list(answer["probabilities"]) if worker_keys != list(ref_keys): problems.append(f"{qid}: candidate order {worker_keys} != {list(ref_keys)}") @@ -191,7 +221,7 @@ def compare_case(name, worker_response, reference, tolerance=PROBABILITY_TOLERAN # source; synthetic references may omit it and fall back to the # shared probabilities and formulas. ref_answer = reference.get("answers", {}).get(qid) - if answer["type"] == "noul": + if expected_type == "noul": scalar_answers = True if ref_answer is not None and "noul" in ref_answer: ref_p_true = ref_answer["noul"] @@ -209,7 +239,7 @@ def compare_case(name, worker_response, reference, tolerance=PROBABILITY_TOLERAN # exposes Valen's choice formula evaluated on the pair. ref_confidence = reference_confidence(ref_probs) max_conf_delta = max(max_conf_delta, abs(answer["confidence"] - ref_confidence)) - elif answer["type"] == "score": + elif expected_type == "score": scalar_answers = True ref_score = ( ref_answer["score"] @@ -261,9 +291,14 @@ def compare_case(name, worker_response, reference, tolerance=PROBABILITY_TOLERAN + ("PASS" if decision_exact else "FAIL: " + "; ".join(problems)) ) prob_ok = max_prob_delta <= tolerance + confidence_ok = max_conf_delta <= tolerance lines.append( f"probability tolerance <= {tolerance:g}: " + ("PASS" if prob_ok else "FAIL") ) + lines.append( + f"confidence tolerance <= {tolerance:g}: " + + ("PASS" if confidence_ok else "FAIL") + ) if scalar_answers: answer_ok = max_answer_delta <= tolerance lines.append( @@ -275,7 +310,9 @@ def compare_case(name, worker_response, reference, tolerance=PROBABILITY_TOLERAN + ("PASS" if legend_exact else "FAIL: " + "; ".join(legend_problems)) ) lines.append("token accounting: " + ("PASS" if tokens_exact else "FAIL")) - passed = decision_exact and legend_exact and prob_ok and tokens_exact + passed = ( + decision_exact and legend_exact and prob_ok and confidence_ok and tokens_exact + ) if scalar_answers: passed = passed and max_answer_delta <= tolerance return lines, passed @@ -352,6 +389,7 @@ def main(argv=None): for name, body in cases: worker_response = decide(json.dumps(body).encode("utf-8"), processor, executor) questions = reference_questions(body, processor.special_tokens) + expected = expected_answer_types(body, processor.special_tokens) with TemporaryDirectory(prefix="valen-parity-") as directory: media_root = Path(directory) if name == "image": @@ -362,7 +400,9 @@ def main(argv=None): reference = run_reference( executor.model, processor.processor, record, media_root, max_length, media_kwargs ) - lines, ok = compare_case(name, worker_response, reference, args.tolerance) + lines, ok = compare_case( + name, worker_response, reference, expected, args.tolerance + ) print("\n".join(lines)) print() if not ok: diff --git a/recipe/valen/test_compare_reference.py b/recipe/valen/test_compare_reference.py index 16e0691d..e716ab36 100644 --- a/recipe/valen/test_compare_reference.py +++ b/recipe/valen/test_compare_reference.py @@ -178,6 +178,7 @@ def request_body(question): {"move": choice_answer({"up": 0.25, "down": 0.75}, "down", 0.5)} ), REFERENCE, + {"move": "choice"}, ) report = "\n".join(lines) check( @@ -185,6 +186,7 @@ def request_body(question): ok and "decision agreement: PASS" in report and "probability tolerance <= 1e-06: PASS" in report + and "confidence tolerance <= 1e-06: PASS" in report and "token accounting: PASS" in report and "input_tokens: 17 == 17" in report, report, @@ -197,6 +199,7 @@ def request_body(question): {"move": choice_answer({"up": 0.25 + 1e-9, "down": 0.75 - 1e-9}, "down", 0.5)} ), REFERENCE, + {"move": "choice"}, ) check("delta within tolerance passes", ok, "\n".join(lines)) @@ -205,6 +208,7 @@ def request_body(question): "text", worker_response({"move": choice_answer({"up": 0.6, "down": 0.4}, "up", 0.2)}), REFERENCE, + {"move": "choice"}, ) check( "probability and decision mismatch fails", @@ -214,6 +218,18 @@ def request_body(question): "\n".join(lines), ) + lines, ok = tool.compare_case( + "text", + worker_response({"move": choice_answer({"up": 0.25, "down": 0.75}, "down", 0.9)}), + REFERENCE, + {"move": "choice"}, + ) + check( + "confidence drift beyond tolerance fails", + not ok and "confidence tolerance <= 1e-06: FAIL" in "\n".join(lines), + "\n".join(lines), + ) + lines, ok = tool.compare_case( "text", worker_response( @@ -221,6 +237,7 @@ def request_body(question): input_tokens=18, ), REFERENCE, + {"move": "choice"}, ) check( "token mismatch fails", @@ -235,6 +252,7 @@ def request_body(question): {"move": choice_answer({"down": 0.75, "up": 0.25}, "down", 0.5)} ), REFERENCE, + {"move": "choice"}, ) check( "candidate order mismatch fails", @@ -262,7 +280,10 @@ def noul_answer(p_true, confidence): } lines, ok = tool.compare_case( - "noul", worker_response({"ask": noul_answer(0.75, 0.5)}), noul_reference + "noul", + worker_response({"ask": noul_answer(0.75, 0.5)}), + noul_reference, + {"ask": "noul"}, ) report = "\n".join(lines) check( @@ -277,6 +298,7 @@ def noul_answer(p_true, confidence): "noul", worker_response({"ask": noul_answer(0.75 + 1e-4, 0.5)}), noul_reference, + {"ask": "noul"}, ) check( "noul answer drift beyond tolerance fails", @@ -284,13 +306,35 @@ def noul_answer(p_true, confidence): "\n".join(lines), ) + # A noul request answered in choice shape must fail even when the + # candidate probabilities, decision and tokens all match. + drifted = { + "type": "choice", + "choice": "true", + "probabilities": {"true": 0.75, "false": 0.25}, + "confidence": 0.5, + } + lines, ok = tool.compare_case( + "noul", worker_response({"ask": drifted}), noul_reference, {"ask": "noul"} + ) + check( + "noul answered as choice fails", + not ok + and "decision agreement: FAIL" in "\n".join(lines) + and "answer type 'choice' != requested 'noul'" in "\n".join(lines), + "\n".join(lines), + ) + choice_shaped = dict(noul_reference) choice_shaped["answers"] = { "ask": {"type": "choice", "probabilities": {"true": 0.75, "false": 0.25}, "choice": "true", "confidence": 0.5} } lines, ok = tool.compare_case( - "noul", worker_response({"ask": noul_answer(0.75, 0.5)}), choice_shaped + "noul", + worker_response({"ask": noul_answer(0.75, 0.5)}), + choice_shaped, + {"ask": "noul"}, ) check( "custom-wording noul compares against the choice-shaped decode", @@ -329,6 +373,7 @@ def score_answer(score, legend, confidence): "score", worker_response({"grade": score_answer(0.4, legend, 0.4)}, compute_tokens=51), score_reference, + {"grade": "score"}, ) report = "\n".join(lines) check( @@ -346,6 +391,7 @@ def score_answer(score, legend, confidence): {"grade": score_answer(0.4, wrong_legend, 0.4)}, compute_tokens=51 ), score_reference, + {"grade": "score"}, ) check( "score legend mismatch fails", @@ -359,6 +405,7 @@ def score_answer(score, legend, confidence): "score", worker_response({"grade": score_answer(0.5, legend, 0.4)}, compute_tokens=51), score_reference, + {"grade": "score"}, ) check( "score expected-level drift fails the answer tolerance", @@ -366,6 +413,28 @@ def score_answer(score, legend, confidence): "\n".join(lines), ) + # A score request answered in choice shape must fail even when the + # candidate probabilities, decision and tokens all match. + drifted_score = { + "type": "choice", + "choice": "1", + "probabilities": {"0": 0.7, "1": 0.2, "2": 0.1}, + "confidence": 0.4, + } + lines, ok = tool.compare_case( + "score", + worker_response({"grade": drifted_score}, compute_tokens=51), + score_reference, + {"grade": "score"}, + ) + check( + "score answered as choice fails", + not ok + and "decision agreement: FAIL" in "\n".join(lines) + and "answer type 'choice' != requested 'score'" in "\n".join(lines), + "\n".join(lines), + ) + failed = 0 for name, passed, detail in results: print(f" {'ok ' if passed else 'FAIL'} {name}") From 75efa400f20438d54c21512d7b8b35107f823843 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 00:01:55 +0800 Subject: [PATCH 06/32] docs(valen): record the parity rerun with the type and confidence gates The four compare_reference blocks were re-run verbatim from the committed tool at 507c448; every block now carries the confidence tolerance verdict and the criteria paragraph states the requested-type and confidence gates. --- recipe/valen/reference.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/recipe/valen/reference.md b/recipe/valen/reference.md index 09cfb240..6475f1e0 100644 --- a/recipe/valen/reference.md +++ b/recipe/valen/reference.md @@ -101,7 +101,7 @@ Pillow: 12.3.0 av: 16.1.0 GPU capability: sm_120 dtype: bfloat16 -system1-omni: 2fd8ce9 (GPU serving and smoke records); parity blocks re-verified verbatim at c9fb2d2 +system1-omni: 2fd8ce9 (GPU serving and smoke records); parity blocks re-verified verbatim at 507c448 ~~~ FP32 and BF16 small-operator checks passed. Startup memory was approximately @@ -393,6 +393,7 @@ max probability delta: 2.7810645453e-08 confidence delta: 5.5621290906e-08 decision agreement: PASS probability tolerance <= 1e-06: PASS +confidence tolerance <= 1e-06: PASS token accounting: PASS ~~~ @@ -409,6 +410,7 @@ max probability delta: 2.6866340819e-08 confidence delta: 5.3732681637e-08 decision agreement: PASS probability tolerance <= 1e-06: PASS +confidence tolerance <= 1e-06: PASS token accounting: PASS ~~~ @@ -420,10 +422,12 @@ share one pinned model instance: a Valen-native record compiled by the pinned `Compiler` from the worker's own record rules, against the production `frontend.valen.decide` pipeline; `noul` and `score` answers are decoded by the pinned reference answer module (`valen.evaluation.inference.answer`). The -criteria are exact discrete agreement (candidate order, chosen key, score -legend), equal token accounting, and the declared tolerance (default `1e-6`, -`--tolerance` to override) on probabilities and the scalar answer fields -(`noul` P(true), `score` expected level): +criteria are exact discrete agreement (candidate order, the requested answer +type, chosen key, score legend), equal token accounting, and the declared +tolerance (default `1e-6`, `--tolerance` to override) on probabilities, +confidences and the scalar answer fields (`noul` P(true), `score` expected +level). An answer whose public `type` drifts from the requested question +type fails the run instead of skipping the noul/score checks: ~~~sh PYTHONPATH=src .venv/bin/python recipe/valen/compare_reference.py \ From 4a89e1a01fed777b191c536899dd8a3142eed21c Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Wed, 14 Oct 2026 23:18:39 +0800 Subject: [PATCH 07/32] valen: add export merge math, downcast and checkpoint-key classification The LoRA merge bf16(fp32(W) + 2*(B@A)) is verified bit-equal against the pinned peft 0.18.1 Linear.merge on synthetic tensors; the FP32->BF16 downcast mirrors the reference's own from_pretrained cast. Checkpoint keys decompose into LoRA pairs, vision-top blocks 20-23, merger and head with strict rejection of anything outside the pinned vision_top layout. --- recipe/valen/export_checkpoint.py | 239 ++++++++++++++++++++++++++++++ tests/valen/test_export.py | 234 +++++++++++++++++++++++++++++ 2 files changed, 473 insertions(+) create mode 100644 recipe/valen/export_checkpoint.py create mode 100644 tests/valen/test_export.py diff --git a/recipe/valen/export_checkpoint.py b/recipe/valen/export_checkpoint.py new file mode 100644 index 00000000..53cf2b29 --- /dev/null +++ b/recipe/valen/export_checkpoint.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Export the pinned Valen checkpoint plus its base to a safetensors set. + +One deterministic command turns the training-framework pickle +(``checkpoint.pt`` + the pinned Qwen3.5-2B base) into the artifact set a +native worker can load, plus a JSON manifest recording every transformation +performed (spec 02): + +- ``model.safetensors`` — the backbone in the base's own key layout + (``model.language_model.*`` / ``model.visual.*``, BF16 only): the language + LoRA merged at BF16 (``bf16(fp32(W) + 2*(B@A))``, bit-equal to peft + 0.18.1's ``Linear.merge``), the trained vision-top blocks 20-23 and the + merger substituted as plain BF16 replacements, and the base's 36 FP32 + tensors (``linear_attn.A_log`` / ``linear_attn.norm.weight``) downcast to + BF16 — the same cast transformers' own BF16 runtime performs. +- ``head.safetensors`` — the decision head's two FP32 projection matrices + (``head.decision.weight``, ``head.candidate.weight``), no bias. +- ``config.json`` — the base config copied verbatim so the reference builder + (``Qwen3_5ForConditionalGeneration.from_pretrained(out, dtype=bf16, + attn_implementation="eager").model``) loads the export unchanged. +- ``valen_export.json`` — the manifest: per-tensor name/shape/dtype and + component attribution (backbone / lora-merged / vision-top / merger / + decision-head), source revisions and SHA-256s, the merge decision and + scaling, the downcast list, the head configuration, and a ``parity`` slot + left ``null`` for the E2 parity gate to fill. + +Determinism: no timestamps, no absolute paths, tensors and JSON written in +sorted key order, single-threaded CPU math — two runs on the same inputs +produce byte-identical artifacts and identical manifests. The export runs +CPU-only and streams tensor-by-tensor (mmap reads, an incremental +safetensors writer), so host memory stays well below the artifact sizes. + +Usage: + + PYTHONPATH=src .venv/bin/python recipe/valen/export_checkpoint.py \ + --checkpoint weights/Valen-Preview-0923 --base weights/Qwen3.5-2B \ + --out weights/valen-export +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import struct +import sys +from pathlib import Path +from typing import NamedTuple + +import torch + +FORMAT = "valen-checkpoint-export/1" +BACKBONE_FILE = "model.safetensors" +HEAD_FILE = "head.safetensors" +MANIFEST_FILE = "valen_export.json" + +# Checkpoint key layout, verified in wayfinder/research/001: PEFT wraps +# backbone.language_model, vision-top/merger tensors ride under +# backbone.visual.*, and the decision head is head.*. +PEFT_LANGUAGE_PREFIX = "backbone.language_model.base_model.model." +PEFT_LORA_SUFFIX_A = ".lora_A.default.weight" +PEFT_LORA_SUFFIX_B = ".lora_B.default.weight" +BACKBONE_PREFIX = "model." +VISUAL_PREFIX = "backbone.visual." +HEAD_PREFIX = "head." + +COMPONENT_BACKBONE = "backbone" +COMPONENT_LORA_MERGED = "lora-merged" +COMPONENT_VISION_TOP = "vision-top" +COMPONENT_MERGER = "merger" +COMPONENT_HEAD = "decision-head" +COMPONENTS = ( + COMPONENT_BACKBONE, + COMPONENT_LORA_MERGED, + COMPONENT_VISION_TOP, + COMPONENT_MERGER, + COMPONENT_HEAD, +) + +# The pinned Preview inventory (research 001, Part 1a): 428 trainable +# tensors decomposing as 372 LoRA keys over the 186 targeted language +# linears, 48 vision-top block tensors, 6 merger tensors and 2 head +# projections; the base file holds 632 tensors of which 36 are FP32. +PINNED_INVENTORY = { + "checkpoint_tensors": 428, + "lora_keys": 372, + "vision_top_keys": 48, + "merger_keys": 6, + "head_keys": 2, + "base_tensors": 632, + "base_fp32_tensors": 36, +} + +# Documented fallback if the E2 parity gate fails (spec 02, user story 9). +MERGE_FALLBACK = ( + "export the FP32 LoRA separately and serve unmerged " + "(spec 02 user story 9; triggered only by a failed parity gate)" +) + +# The shape of the parity slot E2 fills into the manifest (left null here). +PARITY_SLOT_SHAPE = { + "status": "passed | failed", + "device": "torch device string", + "cases": "text and image choice fixtures compared", + "exact_answer_agreement": True, + "max_probability_delta": "<= 1e-6", + "merge_drift": { + "per_layer_max_output_delta": "max |merged - unmerged| per adapted layer", + "aggregate": "worst-case relative drift across layers", + }, + "evidence": "pointer to the recorded parity run", +} + + +def lora_scaling(rank, alpha): + """PEFT's scaling constant alpha/r (64/32 = 2.0 for the pinned config).""" + + return alpha / rank + + +def merge_lora_weight(base_weight, lora_a, lora_b, scaling): + """Merge one LoRA pair into a base weight at the reference's cast + boundaries. + + The delta ``scaling * (B @ A)`` is computed in FP32, added to the + FP32-upcast base weight, and the sum is rounded to the base dtype + (BF16) exactly once — bit-equal to peft 0.18.1's ``Linear.merge`` + (``get_delta_weight`` computes the transpose-free delta in FP32 and the + in-place add rounds once per weight element). Zero-B adapters merge + back to the base tensor bit-exactly. + """ + + base_fp32 = base_weight.to(torch.float32) + delta = lora_b.to(torch.float32) @ lora_a.to(torch.float32) + return (base_fp32 + scaling * delta).to(base_weight.dtype) + + +def downcast_bf16(tensor): + """Downcast a stored-FP32 base tensor to the BF16 the reference runtime + itself keeps (transformers has no keep_in_fp32_modules for qwen3_5 and + upcasts with ``.float()`` at use).""" + + return tensor.to(torch.bfloat16) + + +class Classification(NamedTuple): + """The checkpoint's trainable tensors decomposed by provenance component. + + ``lora`` maps the base tensor name each adapter pair targets to its + ``(lora_A, lora_B)`` checkpoint keys; ``vision_top``/``merger`` pair each + export key (the base's own ``model.visual.*`` layout) with its checkpoint + source; ``head`` maps the export names identically. Everything is kept in + sorted key order so downstream output is deterministic. + """ + + lora: dict # base tensor name -> (a_key, b_key) + vision_top: list # sorted (export_key, checkpoint_key) + merger: list # sorted (export_key, checkpoint_key) + head: dict # export key -> checkpoint key + + +_VISION_BLOCK = re.compile(r"^backbone\.visual\.blocks\.(\d+)\.(.+)$") +_HEAD_TENSORS = ("head.decision.weight", "head.candidate.weight") + + +def classify_checkpoint_weights(keys): + """Map the checkpoint's weight keys onto export names by component. + + Rejects anything outside the pinned ``vision_top`` layout: frozen + backbone tensors, non-PEFT language keys, head biases and unpaired or + non-top-4 vision adapters are all errors, because the export recipe is + only valid for the component inventory research 001 verified. + """ + + lora_a = {} + lora_b = {} + vision_top = [] + merger = [] + head = {} + for key in sorted(keys): + if key.startswith(PEFT_LANGUAGE_PREFIX) and key.endswith(PEFT_LORA_SUFFIX_A): + base_name = ( + "model.language_model." + + key[len(PEFT_LANGUAGE_PREFIX): -len(PEFT_LORA_SUFFIX_A)] + + ".weight" + ) + lora_a[base_name] = key + elif key.startswith(PEFT_LANGUAGE_PREFIX) and key.endswith(PEFT_LORA_SUFFIX_B): + base_name = ( + "model.language_model." + + key[len(PEFT_LANGUAGE_PREFIX): -len(PEFT_LORA_SUFFIX_B)] + + ".weight" + ) + lora_b[base_name] = key + elif key.startswith("backbone.visual.merger."): + merger.append(("model.visual.merger." + key[len("backbone.visual.merger."):], key)) + elif _VISION_BLOCK.match(key): + block, tail = _VISION_BLOCK.match(key).groups() + if int(block) < 20: + raise ValueError( + f"unknown checkpoint weight {key!r}: the vision_top stage " + "trains only the last four blocks (20-23)" + ) + vision_top.append((f"model.visual.blocks.{block}.{tail}", key)) + elif key in _HEAD_TENSORS: + head[key] = key + else: + raise ValueError(f"unknown checkpoint weight {key!r}") + if set(lora_a) != set(lora_b): + missing = sorted(set(lora_a) ^ set(lora_b)) + raise ValueError(f"unpaired LoRA adapters for {missing}") + return Classification( + lora={name: (lora_a[name], lora_b[name]) for name in sorted(lora_a)}, + vision_top=sorted(vision_top), + merger=sorted(merger), + head={name: head[name] for name in sorted(head)}, + ) + + +def validate_inventory(classification, expected): + """Check the classified component counts against the expected inventory.""" + + counts = { + "checkpoint_tensors": 2 * len(classification.lora) + + len(classification.vision_top) + + len(classification.merger) + + len(classification.head), + "lora_keys": 2 * len(classification.lora), + "vision_top_keys": len(classification.vision_top), + "merger_keys": len(classification.merger), + "head_keys": len(classification.head), + } + for name, actual in counts.items(): + wanted = expected[name] + if actual != wanted: + raise ValueError( + f"checkpoint inventory mismatch: {name} is {actual}, expected {wanted}" + ) diff --git a/tests/valen/test_export.py b/tests/valen/test_export.py new file mode 100644 index 00000000..41475b92 --- /dev/null +++ b/tests/valen/test_export.py @@ -0,0 +1,234 @@ +"""Export-tool tests for recipe/valen/export_checkpoint.py (spec 02, E1). + +CPU-only checks of the export's observable contract: the LoRA merge and +FP32->BF16 downcast semantics on synthetic tensors (against the installed +PEFT as the independent reference), checkpoint-key classification, and the +manifest/artifact consistency on a tiny synthetic fixture. The full +determinism run against the real pinned artifacts is env-gated on +VALEN_CKPT / VALEN_BASE, following the CUA_S1_BASE precedent. + +PYTHONPATH=src python -m pytest tests/valen -q +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +RECIPE_VALEN = HERE.parents[1] / "recipe" / "valen" +sys.path.insert(0, str(RECIPE_VALEN)) + +import export_checkpoint as tool # noqa: E402 + + +def test_lora_scaling_is_alpha_over_rank(): + assert tool.lora_scaling(rank=32, alpha=64) == 2.0 + assert tool.lora_scaling(rank=4, alpha=8) == 2.0 + assert tool.lora_scaling(rank=8, alpha=4) == 0.5 + + +def test_merge_matches_peft_merge_bit_exactly(): + """The pinned peft 0.18.1 Linear.merge is the independent source of truth.""" + import torch + import torch.nn as nn + from peft import LoraConfig, get_peft_model + + torch.manual_seed(0) + linear = nn.Linear(8, 6, bias=False).to(torch.bfloat16) + with torch.no_grad(): + linear.weight.copy_(torch.randn(6, 8).to(torch.bfloat16)) + + class Wrapped(nn.Module): + def __init__(self): + super().__init__() + self.lin = linear + + def forward(self, x): + return self.lin(x) + + model = get_peft_model( + Wrapped(), + LoraConfig(r=4, lora_alpha=8, lora_dropout=0.0, target_modules=["lin"], bias="none"), + ) + lora_linear = model.base_model.model.lin + with torch.no_grad(): + lora_linear.lora_A["default"].weight.copy_(torch.randn(4, 8)) + lora_linear.lora_B["default"].weight.copy_(torch.randn(6, 4) * 0.1) + base_weight = linear.weight.detach().clone() + lora_a = lora_linear.lora_A["default"].weight.detach().clone() + lora_b = lora_linear.lora_B["default"].weight.detach().clone() + scaling = lora_linear.scaling["default"] + + lora_linear.merge(safe_merge=False) + peft_merged = linear.weight.detach().clone() + mine = tool.merge_lora_weight(base_weight, lora_a, lora_b, scaling) + assert mine.dtype == torch.bfloat16 + assert torch.equal(mine, peft_merged) + + +def test_merge_hand_computed_rounding(): + """bf16(fp32(W) + 2*(B@A)) with one rounding of the sum, hand-computed.""" + import torch + + # bf16 neighbors around 1.0: 1.0 and 1.0078125 (ulp 2^-7); the rounding + # boundary sits at the midpoint 1.00390625. + base = torch.full((1, 1), 1.0, dtype=torch.bfloat16) + # delta = 2 * B@A = 2 * 0.0015 -> sum 1.003 < midpoint -> rounds to 1.0 + small = tool.merge_lora_weight(base, torch.tensor([[0.0015]]), torch.tensor([[1.0]]), 2.0) + assert small.item() == 1.0 + # delta = 2 * B@A = 2 * 0.002 -> sum 1.004 > midpoint -> rounds to 1.0078125 + large = tool.merge_lora_weight(base, torch.tensor([[0.002]]), torch.tensor([[1.0]]), 2.0) + assert large.item() == 1.0078125 + + +def test_merge_with_zero_lora_b_is_bit_exact_base(): + """Untrained LoRA (zero B, PEFT init) must merge back to the base tensor.""" + import torch + + torch.manual_seed(1) + base = torch.randn(6, 8).to(torch.bfloat16) + lora_a = torch.randn(4, 8) + lora_b = torch.zeros(6, 4) + merged = tool.merge_lora_weight(base, lora_a, lora_b, 2.0) + assert torch.equal(merged, base) + + +def test_downcast_rounds_to_nearest_bf16(): + """FP32 -> BF16 uses round-to-nearest-even, matching the reference's own + from_pretrained(dtype=bf16) cast; already-BF16 input passes through.""" + import torch + + values = torch.tensor( + [1.0, 1.003, 1.004, 0.5, -2.75, 3.14159265], dtype=torch.float32 + ) + downcast = tool.downcast_bf16(values) + assert downcast.dtype == torch.bfloat16 + assert torch.equal(downcast, values.to(torch.bfloat16)) + # A BF16-representable value survives the downcast bit-exactly. + bf16_source = torch.tensor([1.0, -0.5], dtype=torch.bfloat16) + assert torch.equal(tool.downcast_bf16(bf16_source), bf16_source) + + +# --- checkpoint-key classification (synthetic keys in the pinned layout) --- + +def synthetic_checkpoint_keys(): + """A miniature of the 428-key layout: one LoRA pair per family, the + vision-top blocks 20-23, the merger and the head.""" + + lora_modules = [ + "layers.0.linear_attn.in_proj_qkv", + "layers.0.linear_attn.in_proj_z", + "layers.0.linear_attn.in_proj_b", + "layers.0.linear_attn.in_proj_a", + "layers.0.linear_attn.out_proj", + "layers.3.self_attn.q_proj", + "layers.3.self_attn.k_proj", + "layers.3.self_attn.v_proj", + "layers.3.self_attn.o_proj", + "layers.0.mlp.gate_proj", + "layers.0.mlp.up_proj", + "layers.0.mlp.down_proj", + ] + keys = [] + for module in lora_modules: + for side, suffix in (("A", tool.PEFT_LORA_SUFFIX_A), ("B", tool.PEFT_LORA_SUFFIX_B)): + keys.append(tool.PEFT_LANGUAGE_PREFIX + module + suffix) + vision_blocks = [] + for block in (20, 21, 22, 23): + for tail in ( + "norm1.weight", "norm1.bias", "norm2.weight", "norm2.bias", + "attn.qkv.weight", "attn.qkv.bias", + "attn.proj.weight", "attn.proj.bias", + "mlp.linear_fc1.weight", "mlp.linear_fc1.bias", + "mlp.linear_fc2.weight", "mlp.linear_fc2.bias", + ): + vision_blocks.append(f"backbone.visual.blocks.{block}.{tail}") + keys.extend(vision_blocks) + keys.extend( + [ + "backbone.visual.merger.norm.weight", + "backbone.visual.merger.norm.bias", + "backbone.visual.merger.linear_fc1.weight", + "backbone.visual.merger.linear_fc1.bias", + "backbone.visual.merger.linear_fc2.weight", + "backbone.visual.merger.linear_fc2.bias", + "head.decision.weight", + "head.candidate.weight", + ] + ) + return keys + + +def test_classify_checkpoint_weights_layout(): + classification = tool.classify_checkpoint_weights(synthetic_checkpoint_keys()) + # Every LoRA pair maps to the base's own tensor name. + assert classification.lora["model.language_model.layers.0.mlp.down_proj.weight"] == ( + tool.PEFT_LANGUAGE_PREFIX + "layers.0.mlp.down_proj" + tool.PEFT_LORA_SUFFIX_A, + tool.PEFT_LANGUAGE_PREFIX + "layers.0.mlp.down_proj" + tool.PEFT_LORA_SUFFIX_B, + ) + assert len(classification.lora) == 12 + # Vision-top blocks land in the base's model.visual.* layout. + assert ( + "model.visual.blocks.20.attn.qkv.weight", + "backbone.visual.blocks.20.attn.qkv.weight", + ) in classification.vision_top + assert len(classification.vision_top) == 48 + assert ( + "model.visual.merger.linear_fc2.weight", + "backbone.visual.merger.linear_fc2.weight", + ) in classification.merger + assert len(classification.merger) == 6 + assert classification.head == { + "head.decision.weight": "head.decision.weight", + "head.candidate.weight": "head.candidate.weight", + } + + +def test_classify_checkpoint_weights_rejects_unknown_keys(): + import pytest + + for key in ( + "backbone.visual.patch_embed.proj.weight", # frozen, never in checkpoint + "backbone.language_model.layers.0.mlp.down_proj.weight", # base weight, not PEFT + "head.decision.bias", # the head is bias-free + "optimizer.steps", + ): + with pytest.raises(ValueError, match="unknown checkpoint weight"): + tool.classify_checkpoint_weights(synthetic_checkpoint_keys() + [key]) + + +def test_classify_checkpoint_weights_rejects_unpaired_lora(): + import pytest + + keys = synthetic_checkpoint_keys() + keys.remove(tool.PEFT_LANGUAGE_PREFIX + "layers.0.mlp.down_proj" + tool.PEFT_LORA_SUFFIX_B) + with pytest.raises(ValueError, match="unpaired LoRA"): + tool.classify_checkpoint_weights(keys) + + +def test_classify_checkpoint_weights_rejects_non_visual_block_tower(): + import pytest + + # A trainable vision block outside the trained top-4 set contradicts the + # pinned vision_top stage. + with pytest.raises(ValueError, match="vision_top"): + tool.classify_checkpoint_weights( + synthetic_checkpoint_keys() + ["backbone.visual.blocks.19.norm1.weight"] + ) + + +def test_validate_inventory_counts(): + classification = tool.classify_checkpoint_weights(synthetic_checkpoint_keys()) + expected = { + "checkpoint_tensors": 80, + "lora_keys": 24, + "vision_top_keys": 48, + "merger_keys": 6, + "head_keys": 2, + } + tool.validate_inventory(classification, expected) + import pytest + + with pytest.raises(ValueError, match="lora_keys"): + tool.validate_inventory(classification, dict(expected, lora_keys=23)) From 239ec37958f4a15d5a35226ec5f675139c8498ce Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Wed, 14 Oct 2026 23:27:55 +0800 Subject: [PATCH 08/32] valen: stream the exported artifact set and validate its manifest run_export writes model.safetensors (merged backbone in the base's own key layout, BF16 only), the fp32 head.safetensors, the copied base config and the valen_export.json contract through an incremental safetensors writer, tensor by tensor in sorted order, so host memory stays far below the 4.5 GiB artifact set. validate_export re-reads headers and digests and rejects tampered bytes, unlisted tensors, downcast-list drift and head-config drift. The tiny synthetic fixture proves determinism and path independence. --- recipe/valen/export_checkpoint.py | 489 ++++++++++++++++++++++++++++++ tests/valen/test_export.py | 398 ++++++++++++++++++++++++ 2 files changed, 887 insertions(+) diff --git a/recipe/valen/export_checkpoint.py b/recipe/valen/export_checkpoint.py index 53cf2b29..456a174c 100644 --- a/recipe/valen/export_checkpoint.py +++ b/recipe/valen/export_checkpoint.py @@ -237,3 +237,492 @@ def validate_inventory(classification, expected): raise ValueError( f"checkpoint inventory mismatch: {name} is {actual}, expected {wanted}" ) + + +# --- streaming safetensors writer --- +# The pinned base is a single ~4.5 GiB file and host memory is a fraction of +# that, so the export never holds the artifact set in memory: every tensor is +# materialized once, in sorted name order, straight into the output file. +# The byte format is the safetensors v1 spec (8-byte LE header length, JSON +# header padded to an 8-byte boundary, then contiguous tensor data), written +# with fixed ordering so two runs are byte-identical. + +_DTYPE_NAMES = {torch.bfloat16: "BF16", torch.float32: "F32"} +_DTYPE_SIZES = {torch.bfloat16: 2, torch.float32: 4} +_READER_DTYPES = {"BF16": torch.bfloat16, "F32": torch.float32} +_MANIFEST_DTYPES = {"BF16": "bfloat16", "F32": "float32"} +_FP32_TENSOR = re.compile( + r"^model\.language_model\.layers\.\d+\.linear_attn\.(A_log|norm\.weight)$" +) + + +def _tensor_bytes(tensor): + """Raw little-endian bytes of one contiguous tensor (BF16-safe). + + Flattened to 1-D so the byte length is the full payload: ``len()`` of a + multi-dimensional memoryview only reports its first dimension. + """ + + view = tensor.contiguous().view(torch.uint8).reshape(-1) + return memoryview(view.numpy()) + + +def write_safetensors(path, entries): + """Stream ``(name, dtype, shape, materialize)`` entries to ``path``. + + ``materialize`` is called once per entry, in order, so callers can pull + each tensor from an mmap'd source and release it before the next. + Returns ``{"size", "sha256"}`` of the bytes written. + """ + + header = {"__metadata__": {"format": "pt"}} + offset = 0 + for name, dtype, shape, _ in entries: + nbytes = _DTYPE_SIZES[dtype] + for dim in shape: + nbytes *= dim + header[name] = { + "dtype": _DTYPE_NAMES[dtype], + "shape": list(shape), + "data_offsets": [offset, offset + nbytes], + } + offset += nbytes + header_bytes = json.dumps(header, separators=(",", ":")).encode("utf-8") + header_bytes += b" " * (-(8 + len(header_bytes)) % 8) + digest = hashlib.sha256() + with open(path, "wb") as handle: + + def write(chunk): + digest.update(chunk) + handle.write(chunk) + + write(struct.pack(" Date: Wed, 14 Oct 2026 23:38:40 +0800 Subject: [PATCH 09/32] valen: enforce the artifact pins and wire the export CLI export() hashes checkpoint.pt and the base shard, checks them against training_metadata.json, valen_manifest.json and the checkpoint's own base_manifest (both record per-shard sha mappings), refuses any stage but the pinned vision_top layout, and records the revisions, repo and tool versions in the manifest sources. main() runs it as the one-command recipe. The env-gated regression (VALEN_CKPT/VALEN_BASE) exports the real artifacts twice and requires byte-identical output plus the pinned inventory; run on this host it passed in 3m33s with all four files identical across runs. --- recipe/valen/export_checkpoint.py | 179 ++++++++++++++++++++++++++++++ tests/valen/test_export.py | 84 ++++++++++++++ 2 files changed, 263 insertions(+) diff --git a/recipe/valen/export_checkpoint.py b/recipe/valen/export_checkpoint.py index 456a174c..90961949 100644 --- a/recipe/valen/export_checkpoint.py +++ b/recipe/valen/export_checkpoint.py @@ -726,3 +726,182 @@ def validate_export(out_dir): if manifest["parity"] is not None and not isinstance(manifest["parity"], dict): raise ValueError("parity slot must be null or the parity-gate record") return manifest + + +# --- the real-artifact path: pinned checkpoint + pinned base -> export --- + +CHECKPOINT_FILE = "checkpoint.pt" +TRAINING_METADATA_FILE = "training_metadata.json" +BASE_MANIFEST_FILE = "valen_manifest.json" +BASE_INDEX_FILE = "model.safetensors.index.json" +PINNED_STAGE = "vision_top" + + +def sha256_file(path): + """Stream a file through SHA-256 without loading it.""" + + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1 << 22), b""): + digest.update(chunk) + return digest.hexdigest() + + +def checkpoint_revision(checkpoint_dir): + """The HF revision the Preview directory was pinned to, if recorded. + + ``hf download`` leaves a per-revision tree file under .cache; its file + name is the revision. Returns None when the directory carries no pin + (the sha256 remains the authoritative identity either way). + """ + + trees = Path(checkpoint_dir) / ".cache" / "huggingface" / "trees" + if not trees.is_dir(): + return None + revisions = sorted(p.stem for p in trees.glob("*.json")) + if len(revisions) != 1: + return None + return revisions[0] + + +def base_weights_file(base_dir): + """The single safetensors shard the pinned base stores its weights in.""" + + index_path = Path(base_dir) / BASE_INDEX_FILE + index = json.loads(index_path.read_text(encoding="utf-8")) + shards = sorted(set(index["weight_map"].values())) + if len(shards) != 1: + raise ValueError( + f"this recipe exports single-shard bases; {index_path} lists {shards}" + ) + path = Path(base_dir) / shards[0] + if not path.is_file(): + raise ValueError(f"base weights file {path} does not exist") + return path + + +def export(checkpoint_dir, base_dir, out_dir): + """Export the pinned checkpoint + base into ``out_dir`` (must not exist). + + Enforces the artifact-internal pins before writing anything: the + checkpoint digest against training_metadata.json, the base weights + digest against both valen_manifest.json and the checkpoint's own + base_manifest, and the revision agreement between them. Returns the + validated manifest. + """ + + checkpoint_dir = Path(checkpoint_dir) + base_dir = Path(base_dir) + + checkpoint_path = checkpoint_dir / CHECKPOINT_FILE + checkpoint_sha = sha256_file(checkpoint_path) + metadata_path = checkpoint_dir / TRAINING_METADATA_FILE + if metadata_path.is_file(): + declared = json.loads(metadata_path.read_text(encoding="utf-8"))["checkpoint_sha256"] + if declared != checkpoint_sha: + raise ValueError( + f"checkpoint sha256 {checkpoint_sha} != training_metadata {declared}" + ) + + payload = torch.load( + checkpoint_path, map_location="cpu", weights_only=False, mmap=True + ) + config = payload["config"] + if config.get("stage") != PINNED_STAGE: + raise ValueError( + f"checkpoint stage {config.get('stage')!r} != {PINNED_STAGE!r}: this " + "recipe only exports the vision_top Preview layout" + ) + + weights_file = base_weights_file(base_dir) + shard_sums = {weights_file.name: sha256_file(weights_file)} + valen_manifest = json.loads( + (base_dir / BASE_MANIFEST_FILE).read_text(encoding="utf-8") + ) + base_manifest = payload.get("base_manifest") or {} + for label, declared in ( + ("valen_manifest.json", valen_manifest.get("weight_sha256")), + ("checkpoint base_manifest", base_manifest.get("weight_sha256")), + ): + # Both pins record a per-shard sha mapping; they must cover exactly + # the shards on disk and match every digest. + if declared is not None and dict(declared) != shard_sums: + raise ValueError( + f"base weights sha256 {shard_sums} != {label} {dict(declared)}" + ) + base_revision = valen_manifest.get("revision") + if base_manifest.get("revision") not in (None, base_revision): + raise ValueError( + f"base revision {base_revision!r} != checkpoint base_manifest " + f"{base_manifest['revision']!r}" + ) + + base_config = json.loads((base_dir / "config.json").read_text(encoding="utf-8")) + hidden_size = base_config["text_config"]["hidden_size"] + import safetensors + + environment = { + "torch": torch.__version__, + "safetensors": safetensors.__version__, + } + try: + import peft + + environment["peft"] = peft.__version__ + except ImportError: # pragma: no cover - the pinned venv always has peft + pass + + sources = { + "checkpoint": { + "sha256": checkpoint_sha, + "revision": checkpoint_revision(checkpoint_dir), + "stage": config["stage"], + }, + "base": { + "weights_sha256": shard_sums, + "revision": base_revision, + "repo": valen_manifest.get("repo"), + }, + "environment": environment, + } + manifest = run_export( + payload["weights"], + weights_file, + out_dir, + sources=sources, + merge_config={"rank": config["lora_rank"], "alpha": config["lora_alpha"]}, + head_config={ + "projection_dim": config["projection_dim"], + "hidden_size": hidden_size, + "bias": False, + }, + expected=PINNED_INVENTORY, + base_config_path=base_dir / "config.json", + ) + return validate_export(Path(out_dir)) + + +def parse_args(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--checkpoint", required=True, help="Valen-Preview-0923 directory") + parser.add_argument("--base", required=True, help="Qwen3.5-2B base directory") + parser.add_argument("--out", required=True, type=Path, help="export output directory") + return parser.parse_args(argv) + + +def main(argv=None): + args = parse_args(argv) + manifest = export(args.checkpoint, args.base, args.out) + counts = {} + for entry in manifest["tensors"]: + counts[entry["component"]] = counts.get(entry["component"], 0) + 1 + print(f"exported {args.out}") + for component in sorted(counts): + print(f" {component}: {counts[component]} tensors") + print(f" downcasts: {len(manifest['downcasts'])} fp32 -> bf16") + print(f" manifest: {args.out / MANIFEST_FILE}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/valen/test_export.py b/tests/valen/test_export.py index e4aae6ed..1514562d 100644 --- a/tests/valen/test_export.py +++ b/tests/valen/test_export.py @@ -14,6 +14,7 @@ import hashlib import json +import os import sys from pathlib import Path @@ -630,3 +631,86 @@ def drop_file_record(manifest): rewrite_manifest(out, drop_file_record) with pytest.raises(ValueError, match="head.safetensors"): tool.validate_export(out) + + +# --- real-artifact pin helpers (CPU-only, no multi-GB reads) --- + +def test_base_weights_file_requires_single_shard(tmp_path): + import pytest + + base = tmp_path / "base" + base.mkdir() + (base / "model.safetensors.index.json").write_text( + json.dumps({"weight_map": {"a": "model-00001.safetensors"}}), encoding="utf-8" + ) + (base / "model-00001.safetensors").write_bytes(b"") + assert tool.base_weights_file(base) == base / "model-00001.safetensors" + + (base / "model.safetensors.index.json").write_text( + json.dumps( + {"weight_map": {"a": "model-00001.safetensors", "b": "model-00002.safetensors"}} + ), + encoding="utf-8", + ) + with pytest.raises(ValueError, match="single-shard"): + tool.base_weights_file(base) + + +def test_checkpoint_revision_from_tree_pin(tmp_path): + checkpoint = tmp_path / "ckpt" + trees = checkpoint / ".cache" / "huggingface" / "trees" + trees.mkdir(parents=True) + assert tool.checkpoint_revision(checkpoint) is None + (trees / "81b9c6396ded3a45a92ae8079af85f988aa40501.json").write_text("{}", encoding="utf-8") + assert tool.checkpoint_revision(checkpoint) == "81b9c6396ded3a45a92ae8079af85f988aa40501" + (trees / "second.json").write_text("{}", encoding="utf-8") + assert tool.checkpoint_revision(checkpoint) is None + + +# --- the full determinism check against the real pinned artifacts --- +# Set VALEN_CKPT and VALEN_BASE (the recipe reference.md artifact exports) to +# run; it exports twice and compares every byte, mirroring the CUA_S1_BASE +# artifact-gating precedent. + +def test_real_artifact_export_is_deterministic(tmp_path): + import pytest + + if not (os.environ.get("VALEN_CKPT") and os.environ.get("VALEN_BASE")): + pytest.skip("set VALEN_CKPT and VALEN_BASE to run") + + runs = [] + for index in (1, 2): + out = tmp_path / f"run-{index}" + manifest = tool.export( + Path(os.environ["VALEN_CKPT"]), Path(os.environ["VALEN_BASE"]), out + ) + runs.append((out, manifest)) + + (first, manifest_one), (second, manifest_two) = runs + for name in (tool.BACKBONE_FILE, tool.HEAD_FILE, "config.json", tool.MANIFEST_FILE): + assert (first / name).read_bytes() == (second / name).read_bytes(), name + + # The pinned inventory, end to end. + counts = {} + for entry in manifest_one["tensors"]: + counts[entry["component"]] = counts.get(entry["component"], 0) + 1 + # 632 base tensors: 186 merged, 48 vision-top and 6 merger replaced, + # 392 passing through (36 of them downcast from fp32). + assert counts == { + "backbone": 392, + "lora-merged": 186, + "vision-top": 48, + "merger": 6, + "decision-head": 2, + } + assert len(manifest_one["downcasts"]) == 36 + assert manifest_one["merge"]["scaling"] == 2.0 + assert manifest_one["head"]["projection_dim"] == 256 + assert manifest_one["head"]["hidden_size"] == 2048 + assert manifest_one["sources"]["checkpoint"]["sha256"] == ( + "836622efe78fe757e2627aa6050c223d461c424ea430a1c110c15e6f42f5a012" + ) + assert manifest_one["sources"]["base"]["revision"] == ( + "15852e8c16360a2fea060d615a32b45270f8a8fc" + ) + assert manifest_one["parity"] is None From 323277a2d504fca4de13e83e980e9304fc2851f7 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 00:00:14 +0800 Subject: [PATCH 10/32] valen: measure export parity and merge drift, fill the manifest slot Add recipe/valen/verify_export_parity.py (spec 02, E2): it loads the original pinned stack (ValenExecutor: base + unmerged FP32 LoRA + checkpoint) and the exported stack (from_pretrained(weights/valen-export) backbone + DecisionHead rebuilt from head.safetensors, run through the pinned Valen class) serially, one subprocess each so the ~6.6 GiB VRAM footprint never overlaps, answers the recipe's existing text and image choice fixtures through the pinned Compiler, and gates on exact answer agreement plus probability deltas <= 1e-6. It also measures the merge drift of all 186 merged keys on the real tensors (research-001 method: unmerged PEFT semantics vs the exported BF16 GEMM, 256 bf16 probe rows, seed 0, CPU single thread), cross-checks every exported tensor bit-exactly against E1's merge math, fills the manifest's parity slot with the verdict and re-validates the artifact set. tests/valen/test_export_parity.py covers the pure contract CPU-only (bf16 ulp ruler, per-module drift against peft 0.18.1's Linear.forward, aggregation, case verdicts, parity record + slot filling) and wires the env-gated full-artifact regression (VALEN_CKPT/VALEN_BASE/VALEN_SRC + CUDA), asserting the recorded verdict's internal consistency rather than a desired outcome. --- recipe/valen/verify_export_parity.py | 708 +++++++++++++++++++++++++++ tests/valen/test_export_parity.py | 429 ++++++++++++++++ 2 files changed, 1137 insertions(+) create mode 100644 recipe/valen/verify_export_parity.py create mode 100644 tests/valen/test_export_parity.py diff --git a/recipe/valen/verify_export_parity.py b/recipe/valen/verify_export_parity.py new file mode 100644 index 00000000..158bcae7 --- /dev/null +++ b/recipe/valen/verify_export_parity.py @@ -0,0 +1,708 @@ +#!/usr/bin/env python3 +"""Prove the exported Valen artifact set answers like the original checkpoint. + +Spec 02's parity gate (ticket E2). Two model stacks are loaded serially — +only one lives on the GPU at a time, each in its own subprocess so the +~6.6 GiB of VRAM is fully released between loads: + +- original: the pinned reference stack exactly as the worker builds it + (``ValenExecutor`` -> ``valen.modeling.model.build_model`` + + ``load_checkpoint``: pinned base, PEFT-wrapped language model with the + FP32 LoRA applied unmerged, trained vision-top blocks, merger and head + from ``checkpoint.pt``); +- exported: the consumption path the export manifest documents — + ``Qwen3_5ForConditionalGeneration.from_pretrained(weights/valen-export, + dtype=bf16, eager).model`` for the merged backbone plus the decision head + rebuilt from ``head.safetensors`` as the pinned ``DecisionHead`` (2048 -> + 256, FP32, no bias), loaded through the pinned ``Valen`` class so the + forward path is the reference's, not a second invention. + +Both stacks answer the recipe's existing text and image ``choice`` fixtures +(``compare_reference.py``'s text example and ``example-request.json``, +compiled by the pinned ``Compiler`` through the worker's own record rules). +The gate requires exact answer agreement and a probability delta <= 1e-6 +(the tolerance the reference serving already declared). + +The script also measures the merge drift on the real tensors: for each of +the 186 merged keys it compares the unmerged PEFT application (base W plus +the FP32 ``2*(B@A)`` delta on the activations, one final BF16 rounding — +the reference's semantics) against the exported merged BF16 weight's GEMM, +using the research-001 probe protocol (256 bf16 standard-normal rows, seed +0, CPU single thread), and aggregates max/mean. + +Finally it fills the manifest's ``parity`` slot in +``weights/valen-export/valen_export.json`` with the verdict (status, device, +cases, agreement, max probability delta, merge drift aggregate, evidence +pointer) — a reproducible command output, never a hand edit — and +re-validates the artifact set. + +Usage (from the repository root, on the GPU host): + + .venv/bin/python recipe/valen/verify_export_parity.py \ + --valen-source weights/Valen --checkpoint weights/Valen-Preview-0923 \ + --base weights/Qwen3.5-2B --export weights/valen-export --device cuda + +Exit status 0 means the gate passed; 1 means it failed (the spec-02 +fallback — export the FP32 LoRA separately, serve unmerged — then applies). +""" + +from __future__ import annotations + +import argparse +import json +import math +import subprocess +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) + +import compare_reference as reference # noqa: E402 (the recipe's fixture helpers) +import export_checkpoint as exporter # noqa: E402 (E1's export + validation tool) + +PROBABILITY_TOLERANCE = 1e-6 +DRIFT_ROWS = 256 +DRIFT_SEED = 0 +STAGE_SENTINEL = "VERIFY-PARITY-STAGE-RESULT " +PARITY_CASES = ("text", "image") + + +def merge_lora_weight(base_weight, lora_a, lora_b, scaling): + """E1's merge math (bit-equal to peft 0.18.1's ``Linear.merge``).""" + + return exporter.merge_lora_weight(base_weight, lora_a, lora_b, scaling) + + +# --- the drift ruler: bf16 spacing at a magnitude (research 001's ulp column) --- + + +def bf16_ulp_at(magnitude): + """The BF16 grid spacing covering ``magnitude``. + + bf16 keeps 8 significant bits, so values in ``[m * 2^e)`` with + ``m in [0.5, 1)`` (``math.frexp``) sit on a ``2^(e-8)`` grid: spacing + 2^-7 around 1.0, 2^-6 on [2, 4), 2^-5 on [4, 8). + """ + + if magnitude <= 0.0: + return 0.0 + _, exponent = math.frexp(magnitude) + return 2.0 ** (exponent - 8) + + +def module_output_drift(base_weight, lora_a, lora_b, merged_weight, scaling, probe): + """Output drift of one adapted module: unmerged (PEFT, the reference) + vs merged (the exported BF16 weight), following research 001. + + Unmerged arm — peft 0.18.1 ``Linear.forward`` semantics: a BF16 base + GEMM, the ``scaling * (B(A(x)))`` delta computed and added in FP32, one + final rounding back to BF16. Merged arm: the BF16 GEMM against + ``bf16(W + scaling * B @ A)``. ``probe`` is a BF16 activation batch of + shape ``(rows, in_features)``. + """ + + import torch + + if base_weight.dtype != torch.bfloat16 or merged_weight.dtype != torch.bfloat16: + raise ValueError("base and merged weights must be bf16") + y_base = torch.matmul(probe, base_weight.t()) # base_layer(x), BF16 GEMM + delta = scaling * ((probe.float() @ lora_a.t()) @ lora_b.t()) # FP32 LoRA path + y_unmerged = (y_base.float() + delta).to(torch.bfloat16) # one final rounding + y_merged = torch.matmul(probe, merged_weight.t()) + + diff = (y_merged.float() - y_unmerged.float()).abs() + max_abs = float(diff.max()) + max_output = float(y_unmerged.float().abs().max()) + ulp = bf16_ulp_at(max_output) + return { + "max_abs_diff": max_abs, + "max_output_abs": max_output, + "max_rel_diff": (max_abs / max_output) if max_output > 0.0 else 0.0, + "bf16_ulp": ulp, + "ulp_ratio": (max_abs / ulp) if ulp > 0.0 else 0.0, + "bitexact_fraction": float((y_merged == y_unmerged).float().mean()), + } + + +def aggregate_drift(records): + """Max/mean drift across the adapted modules, worst offenders named. + + ``records`` is a list of ``(tensor name, metrics)`` pairs from + :func:`module_output_drift`; ties break deterministically by name. + """ + + if not records: + raise ValueError("no drift records to aggregate") + ordered = sorted(records, key=lambda record: record[0]) + count = len(ordered) + worst_abs = ordered[0] + worst_rel = ordered[0] + for record in ordered[1:]: + if record[1]["max_abs_diff"] > worst_abs[1]["max_abs_diff"]: + worst_abs = record + if record[1]["max_rel_diff"] > worst_rel[1]["max_rel_diff"]: + worst_rel = record + return { + "modules": count, + "max_abs_diff": max(metrics["max_abs_diff"] for _, metrics in ordered), + "mean_abs_diff": sum(m["max_abs_diff"] for _, m in ordered) / count, + "max_rel_diff": max(m["max_rel_diff"] for _, m in ordered), + "mean_rel_diff": sum(m["max_rel_diff"] for _, m in ordered) / count, + "max_ulp_ratio": max(m["ulp_ratio"] for _, m in ordered), + "mean_bitexact_fraction": sum(m["bitexact_fraction"] for _, m in ordered) / count, + "worst_abs_module": worst_abs[0], + "worst_rel_module": worst_rel[0], + } + + +# --- the gate's verdict: one case answered by both stacks --- + + +def compare_case(case, original, exported, tolerance=PROBABILITY_TOLERANCE): + """Compare one fixture answered by the original and exported stacks. + + Each side carries ``questions`` (per qid: candidate ``keys`` in order and + ``probabilities``), the compiler's ``logical_tokens`` and + ``compute_tokens``. The gate requires the same candidate order, exact + answer (argmax) agreement, equal token accounting and probability deltas + within ``tolerance``. + """ + + problems = [] + exact = True + max_delta = 0.0 + original_questions = original["questions"] + exported_questions = exported["questions"] + for qid, record in original_questions.items(): + if qid not in exported_questions: + problems.append(f"{qid}: missing from the exported stack") + exact = False + continue + other = exported_questions[qid] + keys = list(record["keys"]) + if list(other["keys"]) != keys: + problems.append( + f"{qid}: candidate order {list(other['keys'])} != {list(keys)}" + ) + exact = False + continue + for key, p_original, p_exported in zip( + keys, record["probabilities"], other["probabilities"] + ): + max_delta = max(max_delta, abs(p_original - p_exported)) + chosen = max(range(len(keys)), key=record["probabilities"].__getitem__) + other_chosen = max(range(len(keys)), key=other["probabilities"].__getitem__) + if chosen != other_chosen: + problems.append( + f"{qid}: original chose {keys[chosen]!r}, exported {keys[other_chosen]!r}" + ) + exact = False + for qid in sorted(set(exported_questions) - set(original_questions)): + problems.append(f"{qid}: not asked of the original stack") + exact = False + tokens_agree = ( + original["logical_tokens"] == exported["logical_tokens"] + and original["compute_tokens"] == exported["compute_tokens"] + ) + if not tokens_agree: + problems.append( + "token accounting: " + f"({original['logical_tokens']}, {original['compute_tokens']}) != " + f"({exported['logical_tokens']}, {exported['compute_tokens']})" + ) + tolerance_pass = max_delta <= tolerance + if not tolerance_pass: + problems.append( + f"probability delta {max_delta:.6e} exceeds tolerance {tolerance:g}" + ) + return { + "case": case, + "exact_answer_agreement": exact, + "max_probability_delta": max_delta, + "probability_tolerance_pass": tolerance_pass, + "tokens_agree": tokens_agree, + "logical_tokens": int(original["logical_tokens"]), + "compute_tokens": int(original["compute_tokens"]), + "passed": exact and tokens_agree and tolerance_pass, + "problems": problems, + } + + +# --- the manifest parity slot --- + + +def build_parity_record( + *, + status, + device, + hardware, + cases, + exact_answer_agreement, + max_probability_delta, + tolerance, + drift, + evidence, +): + """Assemble the record written into the manifest's ``parity`` slot.""" + + if status not in ("passed", "failed"): + raise ValueError(f"parity status must be 'passed' or 'failed', got {status!r}") + return { + "status": status, + "device": device, + "hardware": hardware, + "tolerance": tolerance, + "cases": cases, + "exact_answer_agreement": bool(exact_answer_agreement), + "max_probability_delta": max_probability_delta, + "merge_drift": drift, + "evidence": evidence, + } + + +def fill_parity_slot(manifest_path, record, *, force=False): + """Write ``record`` into the manifest's ``parity`` slot. + + Refuses to silently replace a different existing record unless ``force`` + is set, keeps the manifest's documented formatting (sorted keys, indent + 2, trailing newline) and returns the updated manifest. + """ + + manifest_path = Path(manifest_path) + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + existing = manifest.get("parity") + if existing is not None and existing != record and not force: + raise ValueError( + "parity slot already holds a different record (pass force=True to replace)" + ) + manifest["parity"] = record + manifest_path.write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return manifest + + +# --- the two model stacks --- + + +def load_original_stack(checkpoint, base, device): + """The reference stack exactly as the worker's executor builds it.""" + + sys.path.insert(0, str(HERE.parents[1] / "src")) + from models.valen.engine import ValenExecutor + + executor = ValenExecutor(Path(checkpoint), Path(base), device, "bf16") + return executor + + +def load_exported_stack(export_dir, projection_dim, device): + """The exported artifact set loaded through its documented consumption + path: ``from_pretrained`` backbone + the head rebuilt from + ``head.safetensors`` into the pinned ``Valen`` class.""" + + import torch + from safetensors import safe_open + from transformers import Qwen3_5ForConditionalGeneration + + from valen.modeling.model import Valen + + container, loading_info = Qwen3_5ForConditionalGeneration.from_pretrained( + str(export_dir), + dtype=torch.bfloat16, + attn_implementation="eager", + local_files_only=True, + output_loading_info=True, + ) + if any( + loading_info.get(key) + for key in ("missing_keys", "unexpected_keys", "mismatched_keys", "error_msgs") + ): + raise ValueError(f"Incomplete exported model load: {loading_info}") + backbone = container.model + del container # the generation/vocabulary layer is never executed + backbone.requires_grad_(False) + model = Valen(backbone, projection_dim) + with safe_open(Path(export_dir) / exporter.HEAD_FILE, framework="pt") as handle: + head_state = { + key[len(exporter.HEAD_PREFIX):]: handle.get_tensor(key) + for key in sorted(handle.keys()) + } + model.head.load_state_dict(head_state) + model.to(device) + model.eval() + return model + + +# --- answering the fixtures through one loaded stack --- + + +def choice_fixtures(): + """The recipe's existing text and image ``choice`` fixtures.""" + + return ( + ("text", reference.DEFAULT_TEXT_BODY, "compare_reference.py DEFAULT_TEXT_BODY"), + ( + "image", + json.loads((HERE / "example-request.json").read_text(encoding="utf-8")), + "recipe/valen/example-request.json", + ), + ) + + +def run_fixtures(model, processor, max_length, media_kwargs): + """Answer every fixture with one loaded stack; return per-case results.""" + + import torch + + from valen.data.compiler import Compiler + + cases = {} + with torch.no_grad(): + for name, body, fixture in choice_fixtures(): + questions = reference.reference_questions(body, processor.special_tokens) + with TemporaryDirectory(prefix="valen-parity-") as directory: + media_root = Path(directory) + if name == "image": + image_bytes, image_format = reference.decode_image_data_url( + body["state"]["image"] + ) + record = reference.reference_image_record( + image_bytes, image_format, questions, media_root + ) + else: + record = reference.reference_text_record(body["state"], questions) + compiler = Compiler( + processor.processor, + media_root=media_root, + max_length=max_length, + media_kwargs=media_kwargs, + ) + state = compiler.compile(record) + answered = {} + for question in state.questions: + logits = model(question).float().cpu() + answered[question.qid] = { + "keys": list(question.keys), + "probabilities": torch.softmax(logits, dim=-1).tolist(), + } + cases[name] = { + "fixture": fixture, + "questions": answered, + "logical_tokens": int(state.logical_tokens), + "compute_tokens": int(state.compute_tokens), + } + return cases + + +def stage_environment(args): + """Verify the pinned source and build the shared compile-side state.""" + + sys.path.insert(0, str(HERE.parents[1] / "src")) + from frontend.valen import _verify_source_revision + + source = Path(args.valen_source).resolve() + _verify_source_revision(source) + sys.path.insert(0, str(source)) + + from models.valen.preprocess import ValenProcessor + + config = json.loads( + (Path(args.checkpoint) / "config.json").read_text(encoding="utf-8") + ) + processor = ValenProcessor( + Path(args.base), int(config["max_length"]), config.get("media_kwargs") + ) + return processor, int(config["max_length"]), config.get("media_kwargs") or {} + + +def run_stage(args): + """Load one stack in this (sub)process, answer the fixtures, emit JSON.""" + + import torch + + torch.set_grad_enabled(False) + processor, max_length, media_kwargs = stage_environment(args) + + if args.stage == "original": + executor = load_original_stack(args.checkpoint, args.base, args.device) + model = executor.model + else: + manifest = json.loads( + (Path(args.export) / exporter.MANIFEST_FILE).read_text(encoding="utf-8") + ) + model = load_exported_stack( + Path(args.export), manifest["head"]["projection_dim"], args.device + ) + + device = str(next(model.parameters()).device) + hardware = None + if device.startswith("cuda"): + index = torch.device(device).index or 0 + capability = torch.cuda.get_device_capability(index) + free, total = torch.cuda.mem_get_info(index) + hardware = ( + f"{torch.cuda.get_device_name(index)} " + f"(sm_{capability[0]}{capability[1]}, {total / (1024 ** 3):.2f} GiB)" + ) + try: + cases = run_fixtures(model, processor, max_length, media_kwargs) + finally: + if args.stage == "original": + executor.close() + del model + if device.startswith("cuda"): + torch.cuda.empty_cache() + print( + STAGE_SENTINEL + + json.dumps( + { + "stage": args.stage, + "device": device, + "hardware": hardware, + "torch": torch.__version__, + "cases": cases, + } + ) + ) + return 0 + + +def run_stage_subprocess(args, stage): + """Run one stack in its own subprocess so VRAM is fully released.""" + + command = [ + sys.executable, + str(Path(__file__).resolve()), + "--valen-source", args.valen_source, + "--checkpoint", args.checkpoint, + "--base", args.base, + "--export", args.export, + "--device", args.device, + "--stage", stage, + ] + result = subprocess.run(command, capture_output=True, text=True, timeout=3600) + if result.returncode != 0: + raise RuntimeError( + f"{stage} stage failed with exit {result.returncode}:\n" + f"{result.stdout[-2000:]}\n{result.stderr[-4000:]}" + ) + payload = None + for line in result.stdout.splitlines(): + if line.startswith(STAGE_SENTINEL): + payload = json.loads(line[len(STAGE_SENTINEL):]) + if payload is None: + raise RuntimeError(f"{stage} stage produced no result: {result.stdout[-2000:]}") + return payload + + +# --- the merge-drift measurement on the real tensors --- + + +def measure_merge_drift(checkpoint_dir, base_dir, export_dir, *, rows=DRIFT_ROWS, seed=DRIFT_SEED): + """Per-module drift of the 186 merged keys on the real tensors. + + The unmerged arm uses the base file's BF16 weight plus the checkpoint's + FP32 LoRA (reference semantics); the merged arm uses the tensor actually + sitting in the exported ``model.safetensors``, cross-checked bit-exactly + against E1's merge math so the measurement can never run against a + silently different weight. Probe protocol: ``rows`` BF16 standard-normal + activations per module, one generator seeded once, modules in sorted + name order, CPU single thread (the research-001 method). + """ + + import torch + from safetensors import safe_open + + torch.set_grad_enabled(False) + torch.set_num_threads(1) + + payload = torch.load( + Path(checkpoint_dir) / exporter.CHECKPOINT_FILE, + map_location="cpu", + weights_only=False, + mmap=True, + ) + config = payload["config"] + scaling = exporter.lora_scaling(config["lora_rank"], config["lora_alpha"]) + classification = exporter.classify_checkpoint_weights(payload["weights"]) + exporter.validate_inventory(classification, exporter.PINNED_INVENTORY) + + base_file = exporter.base_weights_file(base_dir) + records = [] + torch.manual_seed(seed) + with ( + safe_open(base_file, framework="pt", device="cpu") as base, + safe_open(Path(export_dir) / exporter.BACKBONE_FILE, framework="pt", device="cpu") as exported, + ): + for name, (a_key, b_key) in sorted(classification.lora.items()): + base_weight = base.get_tensor(name) + lora_a = payload["weights"][a_key] + lora_b = payload["weights"][b_key] + merged = exported.get_tensor(name) + expected = merge_lora_weight(base_weight, lora_a, lora_b, scaling) + if not torch.equal(merged, expected): + raise ValueError( + f"exported tensor {name!r} is not bf16(W + {scaling:g}*(B@A)) of " + "the given base and checkpoint" + ) + probe = torch.randn(rows, base_weight.shape[1]).to(torch.bfloat16) + records.append( + (name, module_output_drift(base_weight, lora_a, lora_b, merged, scaling, probe)) + ) + return records, scaling + + +# --- reporting --- + + +def format_case_report(verdict, tolerance): + lines = [f"case: {verdict['case']}"] + lines.append(f"logical tokens: {verdict['logical_tokens']}") + lines.append(f"compute tokens: {verdict['compute_tokens']}") + lines.append(f"max probability delta: {verdict['max_probability_delta']:.10e}") + lines.append( + "answer agreement: " + ("PASS" if verdict["exact_answer_agreement"] else "FAIL") + ) + lines.append( + f"probability tolerance <= {tolerance:g}: " + + ("PASS" if verdict["probability_tolerance_pass"] else "FAIL") + ) + lines.append("token accounting: " + ("PASS" if verdict["tokens_agree"] else "FAIL")) + for problem in verdict["problems"]: + lines.append(f"problem: {problem}") + return lines + + +def format_drift_report(aggregate, *, rows, seed, scaling): + lines = [ + f"merge drift: {aggregate['modules']} adapted modules " + f"(probe {rows}x bf16 rows, seed {seed}, scaling {scaling:g}, cpu single thread)", + f" max output delta: {aggregate['max_abs_diff']:.6e} " + f"(mean {aggregate['mean_abs_diff']:.6e}), worst {aggregate['worst_abs_module']}", + f" max relative output drift: {aggregate['max_rel_diff']:.6e} " + f"(mean {aggregate['mean_rel_diff']:.6e}), worst {aggregate['worst_rel_module']}", + f" max ulp ratio: {aggregate['max_ulp_ratio']:.6g}, " + f"mean bit-exact fraction: {aggregate['mean_bitexact_fraction']:.6f}", + ] + return lines + + +def evidence_pointer(): + return ( + "recipe/valen/reference.md (export parity block); reproduce with " + "recipe/valen/verify_export_parity.py --valen-source $VALEN_SRC " + "--checkpoint $VALEN_CKPT --base $VALEN_BASE --export --device cuda" + ) + + +def parse_args(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--valen-source", required=True, help="pinned Valen source checkout") + parser.add_argument("--checkpoint", required=True, help="Valen-Preview-0923 directory") + parser.add_argument("--base", required=True, help="Qwen3.5-2B base directory") + parser.add_argument("--export", required=True, type=Path, help="exported artifact directory") + parser.add_argument("--device", default="cuda") + parser.add_argument("--tolerance", type=float, default=PROBABILITY_TOLERANCE) + parser.add_argument("--rows", type=int, default=DRIFT_ROWS, help="drift probe rows per module") + parser.add_argument("--seed", type=int, default=DRIFT_SEED, help="drift probe seed") + parser.add_argument("--stage", choices=("original", "export"), default=None, help=argparse.SUPPRESS) + parser.add_argument("--force", action="store_true", help="replace an existing parity record") + return parser.parse_args(argv) + + +def verify_sources_match_manifest(args, manifest): + """The parity evidence only holds for the artifacts the export recorded.""" + + checkpoint_sha = exporter.sha256_file(Path(args.checkpoint) / exporter.CHECKPOINT_FILE) + recorded = manifest["sources"]["checkpoint"]["sha256"] + if recorded != checkpoint_sha: + raise ValueError( + f"the export was built from checkpoint sha256 {recorded}, but " + f"--checkpoint hashes {checkpoint_sha}" + ) + base_manifest = json.loads( + (Path(args.base) / exporter.BASE_MANIFEST_FILE).read_text(encoding="utf-8") + ) + revision = manifest["sources"]["base"]["revision"] + if base_manifest.get("revision") != revision: + raise ValueError( + f"the export was built from base revision {revision}, but --base " + f"pins {base_manifest.get('revision')}" + ) + + +def main(argv=None): + args = parse_args(argv) + if args.stage is not None: + return run_stage(args) + + export_dir = Path(args.export) + manifest = exporter.validate_export(export_dir) + verify_sources_match_manifest(args, manifest) + + original = run_stage_subprocess(args, "original") + exported = run_stage_subprocess(args, "export") + verdicts = [ + compare_case(name, original["cases"][name], exported["cases"][name], args.tolerance) + for name in PARITY_CASES + ] + + records, scaling = measure_merge_drift( + args.checkpoint, args.base, export_dir, rows=args.rows, seed=args.seed + ) + aggregate = aggregate_drift(records) + + status = "passed" if all(verdict["passed"] for verdict in verdicts) else "failed" + record = build_parity_record( + status=status, + device=original["device"], + hardware=original["hardware"], + cases=[ + { + "case": verdict["case"], + "fixture": original["cases"][verdict["case"]]["fixture"], + "exact_answer_agreement": verdict["exact_answer_agreement"], + "max_probability_delta": verdict["max_probability_delta"], + "logical_tokens": verdict["logical_tokens"], + "compute_tokens": verdict["compute_tokens"], + "passed": verdict["passed"], + "problems": verdict["problems"], + } + for verdict in verdicts + ], + exact_answer_agreement=all(v["exact_answer_agreement"] for v in verdicts), + max_probability_delta=max(v["max_probability_delta"] for v in verdicts), + tolerance=args.tolerance, + drift={ + "method": ( + "per adapted module on real tensors: unmerged = bf16 GEMM(W) plus " + "fp32 2*(B@A) delta on activations with one final bf16 rounding " + "(peft 0.18.1 Linear.forward); merged = bf16 GEMM(bf16(W + 2*(B@A))) " + "from the export; probe {rows}x bf16 standard-normal rows, seed {seed}, " + "cpu single thread (wayfinder/research/001 method)" + ).format(rows=args.rows, seed=args.seed), + "modules": aggregate["modules"], + "aggregate": aggregate, + }, + evidence=evidence_pointer(), + ) + fill_parity_slot(export_dir / exporter.MANIFEST_FILE, record, force=args.force) + exporter.validate_export(export_dir) + + for verdict in verdicts: + print("\n".join(format_case_report(verdict, args.tolerance))) + print() + print("\n".join(format_drift_report(aggregate, rows=args.rows, seed=args.seed, scaling=scaling))) + print() + print(f"device: {record['device']} ({record['hardware']})") + print(f"parity slot filled: {export_dir / exporter.MANIFEST_FILE}") + if status == "failed": + print(f"merge fallback: {manifest['merge']['fallback']}") + print("verify_export_parity: ok" if status == "passed" else "verify_export_parity: FAILED") + return 0 if status == "passed" else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/valen/test_export_parity.py b/tests/valen/test_export_parity.py new file mode 100644 index 00000000..fba88f6e --- /dev/null +++ b/tests/valen/test_export_parity.py @@ -0,0 +1,429 @@ +"""Parity-gate tests for recipe/valen/verify_export_parity.py (spec 02, E2). + +CPU-only checks of the export-parity tool's observable contract: the +merge-drift measurement (PEFT unmerged vs merged BF16 semantics, the +research-001 method) on hand-computed tensors, the drift aggregation, the +original-vs-exported case comparison verdicts, and the manifest parity-slot +filling. The full parity gate against the real pinned artifacts is env-gated +on VALEN_CKPT / VALEN_BASE / VALEN_SRC plus CUDA, following the test_export.py +precedent. + +PYTHONPATH=src python -m pytest tests/valen -q +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +RECIPE_VALEN = HERE.parents[1] / "recipe" / "valen" +sys.path.insert(0, str(RECIPE_VALEN)) + +import verify_export_parity as tool # noqa: E402 + + +# --- the BF16 ulp ruler (research 001 reports spacing at max |y|) --- + +def test_bf16_ulp_at_known_magnitudes(): + # bf16 keeps 8 significant bits, so values in [2^e, 2^(e+1)) sit on a + # 2^(e-7) grid: spacing 2^-7 at 1.0, 2^-6 on [2, 4), 2^-5 on [4, 8). + assert tool.bf16_ulp_at(1.0) == 2.0**-7 + assert tool.bf16_ulp_at(1.0078125) == 2.0**-7 + assert tool.bf16_ulp_at(3.015625) == 2.0**-6 + assert tool.bf16_ulp_at(6.0) == 2.0**-5 + assert tool.bf16_ulp_at(0.25) == 2.0**-9 + assert tool.bf16_ulp_at(16.0) == 2.0**-3 + + +# --- per-module output drift: unmerged (PEFT) vs merged BF16 GEMM --- + +def test_module_output_drift_zero_b_adapter_is_bit_exact(): + """An untrained LoRA (zero B) leaves both outputs bit-identical.""" + import torch + + torch.manual_seed(0) + base = torch.randn(6, 8).to(torch.bfloat16) + merged = tool.merge_lora_weight(base, torch.randn(4, 8), torch.zeros(6, 4), 2.0) + probe = torch.randn(16, 8).to(torch.bfloat16) + metrics = tool.module_output_drift( + base, torch.randn(4, 8), torch.zeros(6, 4), merged, 2.0, probe + ) + assert metrics["max_abs_diff"] == 0.0 + assert metrics["bitexact_fraction"] == 1.0 + assert metrics["max_rel_diff"] == 0.0 + + +def test_module_output_drift_hand_computed_one_ulp(): + """The rounding-order split, hand-computed on a 1x1 module. + + base W = 1.0, probe x = 3.0 (both bf16), delta weight 2*(B@A) = 0.0038: + + - merged: W' = bf16(1.0038) = 1.0 (below the 1.00390625 midpoint), so + y_merged = 3.0 exactly; + - unmerged (PEFT): y = bf16(3.0 + 3*0.0038) = bf16(3.0114) = 3.015625 + (above the 3.0078125 midpoint of the [2, 4) grid). + + So the outputs differ by exactly one bf16 ulp of the output. + """ + import torch + + base = torch.tensor([[1.0]], dtype=torch.bfloat16) + lora_a = torch.tensor([[0.0019]], dtype=torch.float32) + lora_b = torch.tensor([[1.0]], dtype=torch.float32) + merged = tool.merge_lora_weight(base, lora_a, lora_b, 2.0) + assert merged.item() == 1.0 # the weight rounds back to the base + + probe = torch.tensor([[3.0]], dtype=torch.bfloat16) + metrics = tool.module_output_drift(base, lora_a, lora_b, merged, 2.0, probe) + assert metrics["max_abs_diff"] == pytest.approx(0.015625) + assert metrics["max_output_abs"] == pytest.approx(3.015625) + assert metrics["bf16_ulp"] == pytest.approx(0.015625) + assert metrics["ulp_ratio"] == pytest.approx(1.0) + assert metrics["max_rel_diff"] == pytest.approx(0.015625 / 3.015625) + assert metrics["bitexact_fraction"] == 0.0 + + +def test_module_output_drift_matches_peft_layer_forward(): + """The unmerged arm must reproduce peft 0.18.1's Linear.forward on the + same tensors: bf16 base GEMM, FP32 LoRA path, one final rounding.""" + + import torch + import torch.nn as nn + from peft import LoraConfig, get_peft_model + + torch.manual_seed(3) + linear = nn.Linear(8, 6, bias=False).to(torch.bfloat16) + with torch.no_grad(): + linear.weight.copy_(torch.randn(6, 8).to(torch.bfloat16)) + + class Wrapped(nn.Module): + def __init__(self): + super().__init__() + self.lin = linear + + def forward(self, x): + return self.lin(x) + + model = get_peft_model( + Wrapped(), + LoraConfig(r=4, lora_alpha=8, lora_dropout=0.0, target_modules=["lin"], bias="none"), + ) + lora_linear = model.base_model.model.lin + with torch.no_grad(): + lora_linear.lora_A["default"].weight.copy_(torch.randn(4, 8)) + lora_linear.lora_B["default"].weight.copy_(torch.randn(6, 4) * 0.05) + scaling = lora_linear.scaling["default"] + base_weight = linear.weight.detach().clone() + lora_a = lora_linear.lora_A["default"].weight.detach().clone() + lora_b = lora_linear.lora_B["default"].weight.detach().clone() + merged = tool.merge_lora_weight(base_weight, lora_a, lora_b, scaling) + probe = torch.randn(32, 8).to(torch.bfloat16) + + model.eval() + with torch.no_grad(): + peft_unmerged = model(probe).detach() + + metrics = tool.module_output_drift(base_weight, lora_a, lora_b, merged, scaling, probe) + assert metrics["max_abs_diff"] > 0.0 # the merge really does drift + assert metrics["bitexact_fraction"] < 1.0 + # Recomputing the unmerged arm by hand must agree with peft bit-exactly: + # the drift tool's semantics are the reference's, not a re-invention. + y_base = torch.matmul(probe, base_weight.t()) + delta = scaling * ((probe.float() @ lora_a.t()) @ lora_b.t()) + y_unmerged = (y_base.float() + delta).to(torch.bfloat16) + assert torch.equal(y_unmerged, peft_unmerged.to(torch.bfloat16)) + + +# --- drift aggregation across the adapted modules --- + +def drift_metrics(max_abs, max_out, ulp, bitexact): + return { + "max_abs_diff": max_abs, + "max_output_abs": max_out, + "max_rel_diff": max_abs / max_out, + "bf16_ulp": ulp, + "ulp_ratio": max_abs / ulp, + "bitexact_fraction": bitexact, + } + + +def test_aggregate_drift_reports_max_mean_and_worst_modules(): + records = [ + ("layers.0.mlp.down_proj.weight", drift_metrics(0.03125, 6.0, 0.03125, 0.5)), + ("layers.0.mlp.gate_proj.weight", drift_metrics(0.015625, 3.0, 0.015625, 0.6)), + ("layers.3.self_attn.q_proj.weight", drift_metrics(0.03125, 8.0, 0.0625, 0.4)), + ] + aggregate = tool.aggregate_drift(records) + assert aggregate["modules"] == 3 + assert aggregate["max_abs_diff"] == pytest.approx(0.03125) + assert aggregate["mean_abs_diff"] == pytest.approx((0.03125 + 0.015625 + 0.03125) / 3) + assert aggregate["max_rel_diff"] == pytest.approx(0.03125 / 6.0) + assert aggregate["mean_rel_diff"] == pytest.approx( + (0.03125 / 6.0 + 0.015625 / 3.0 + 0.03125 / 8.0) / 3 + ) + assert aggregate["max_ulp_ratio"] == pytest.approx(1.0) + assert aggregate["mean_bitexact_fraction"] == pytest.approx(0.5) + # Ties break deterministically by module name. + assert aggregate["worst_abs_module"] == "layers.0.mlp.down_proj.weight" + assert aggregate["worst_rel_module"] == "layers.0.mlp.down_proj.weight" + + +def test_aggregate_drift_requires_records(): + with pytest.raises(ValueError, match="no drift records"): + tool.aggregate_drift([]) + + +# --- the original-vs-exported case comparison (the gate's verdict) --- + +def case_side(probs, keys=("refund", "wait"), logical=48, compute=48): + return { + "questions": {"refund": {"keys": list(keys), "probabilities": probs}}, + "logical_tokens": logical, + "compute_tokens": compute, + } + + +def test_compare_case_identical_stacks_pass(): + original = case_side([0.5159631, 0.4840369]) + verdict = tool.compare_case("text", original, case_side([0.5159631, 0.4840369])) + assert verdict["exact_answer_agreement"] is True + assert verdict["max_probability_delta"] == 0.0 + assert verdict["probability_tolerance_pass"] is True + assert verdict["tokens_agree"] is True + assert verdict["passed"] is True + assert verdict["problems"] == [] + + +def test_compare_case_delta_within_tolerance_passes(): + original = case_side([0.5159631, 0.4840369]) + drifted = case_side([0.5159631 + 5e-7, 0.4840369 - 5e-7]) + verdict = tool.compare_case("text", original, drifted) + assert verdict["passed"] is True + assert verdict["max_probability_delta"] == pytest.approx(5e-7) + assert verdict["exact_answer_agreement"] is True + + +def test_compare_case_delta_beyond_tolerance_fails(): + original = case_side([0.5159631, 0.4840369]) + drifted = case_side([0.5159631 + 2e-6, 0.4840369 - 2e-6]) + verdict = tool.compare_case("text", original, drifted) + assert verdict["exact_answer_agreement"] is True # argmax still agrees + assert verdict["probability_tolerance_pass"] is False + assert verdict["passed"] is False + assert any("tolerance" in problem for problem in verdict["problems"]) + + +def test_compare_case_flipped_answer_fails(): + original = case_side([0.5159631, 0.4840369]) + flipped = case_side([0.4840369, 0.5159631]) + verdict = tool.compare_case("text", original, flipped) + assert verdict["exact_answer_agreement"] is False + assert verdict["passed"] is False + + +def test_compare_case_candidate_order_mismatch_fails(): + original = case_side([0.5159631, 0.4840369]) + reordered = case_side([0.4840369, 0.5159631], keys=("wait", "refund")) + verdict = tool.compare_case("text", original, reordered) + assert verdict["passed"] is False + assert any("candidate order" in problem for problem in verdict["problems"]) + + +def test_compare_case_missing_question_fails(): + original = case_side([0.5159631, 0.4840369]) + emptied = case_side([0.5159631, 0.4840369]) + emptied["questions"] = {} + verdict = tool.compare_case("text", original, emptied) + assert verdict["passed"] is False + + +def test_compare_case_token_accounting_must_agree(): + original = case_side([0.5159631, 0.4840369]) + retokenized = case_side([0.5159631, 0.4840369], compute=49) + verdict = tool.compare_case("text", original, retokenized) + assert verdict["tokens_agree"] is False + assert verdict["passed"] is False + + +# --- the manifest parity slot --- + +def test_build_parity_record_covers_the_documented_slot_shape(): + record = tool.build_parity_record( + status="passed", + device="cuda:0", + hardware="NVIDIA GeForce RTX 5060 Laptop GPU (sm_120)", + cases=[{"case": "text", "passed": True}], + exact_answer_agreement=True, + max_probability_delta=2.7e-08, + tolerance=1e-6, + drift={"modules": 186, "max_abs_diff": 0.03125}, + evidence="recipe/valen/reference.md export-parity block", + ) + # Every key the manifest's parity_slot_shape documents must be present. + assert set(record) >= { + "status", "device", "cases", "exact_answer_agreement", + "max_probability_delta", "merge_drift", "evidence", + } + assert record["status"] == "passed" + assert record["max_probability_delta"] == 2.7e-08 + assert record["tolerance"] == 1e-6 + assert record["merge_drift"] == {"modules": 186, "max_abs_diff": 0.03125} + + +def test_build_parity_record_rejects_unknown_status(): + with pytest.raises(ValueError, match="status"): + tool.build_parity_record( + status="maybe", + device="cuda:0", + hardware="gpu", + cases=[], + exact_answer_agreement=True, + max_probability_delta=0.0, + tolerance=1e-6, + drift={}, + evidence="", + ) + + +def minimal_manifest(path, parity=None): + manifest = { + "format": "valen-checkpoint-export/1", + "parity": parity, + "parity_slot_shape": {"status": "passed | failed"}, + } + Path(path).write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return manifest + + +def parity_record(status="passed", delta=1e-8): + return tool.build_parity_record( + status=status, + device="cuda:0", + hardware="gpu", + cases=[{"case": "text", "passed": status == "passed"}], + exact_answer_agreement=status == "passed", + max_probability_delta=delta, + tolerance=1e-6, + drift={"modules": 186}, + evidence="recipe/valen/reference.md", + ) + + +def test_fill_parity_slot_replaces_null(tmp_path): + manifest_path = tmp_path / "valen_export.json" + minimal_manifest(manifest_path) + record = parity_record() + updated = tool.fill_parity_slot(manifest_path, record) + assert updated["parity"] == record + reread = json.loads(manifest_path.read_text(encoding="utf-8")) + assert reread["parity"] == record + # The documented formatting (sorted, indent 2, trailing newline) is kept. + assert manifest_path.read_text(encoding="utf-8").endswith("}\n") + + +def test_fill_parity_slot_is_idempotent_for_the_same_record(tmp_path): + manifest_path = tmp_path / "valen_export.json" + minimal_manifest(manifest_path) + record = parity_record() + tool.fill_parity_slot(manifest_path, record) + before = manifest_path.read_text(encoding="utf-8") + tool.fill_parity_slot(manifest_path, record) + assert manifest_path.read_text(encoding="utf-8") == before + + +def test_fill_parity_slot_refuses_to_silently_overwrite(tmp_path): + manifest_path = tmp_path / "valen_export.json" + minimal_manifest(manifest_path) + tool.fill_parity_slot(manifest_path, parity_record(delta=1e-8)) + with pytest.raises(ValueError, match="parity slot already holds"): + tool.fill_parity_slot(manifest_path, parity_record(delta=5e-3)) + # The earlier record survives the refusal. + assert json.loads(manifest_path.read_text(encoding="utf-8"))["parity"][ + "max_probability_delta" + ] == 1e-8 + + +# --- the full parity gate against the real pinned artifacts --- +# Set VALEN_CKPT, VALEN_BASE and VALEN_SRC (the recipe reference.md artifact +# exports) on a CUDA host to run: it exports a fresh artifact set, then runs +# the two serial model loads (original checkpoint stack, exported stack), +# the 186-module merge-drift measurement and the manifest parity fill. +# +# The gate's exit status is evidence, not a regression invariant: on the +# pinned artifacts the declared 1e-6 tolerance is exceeded by the measured +# merge drift (recorded honestly in recipe/valen/reference.md), so the test +# asserts the verdict's internal consistency — the recorded status, deltas, +# agreement and drift must match what the tool actually measured, and the +# exact-answer agreement of the pinned artifacts must hold. + +def test_real_artifact_export_parity_gate(tmp_path): + import subprocess + import torch + + env_vars = ("VALEN_CKPT", "VALEN_BASE", "VALEN_SRC") + if not all(os.environ.get(name) for name in env_vars): + pytest.skip("set VALEN_CKPT, VALEN_BASE and VALEN_SRC to run") + if not torch.cuda.is_available(): + pytest.skip("needs a CUDA device") + + import export_checkpoint as exporter + + export_dir = tmp_path / "export" + exporter.export( + Path(os.environ["VALEN_CKPT"]), Path(os.environ["VALEN_BASE"]), export_dir + ) + + result = subprocess.run( + [ + sys.executable, + str(RECIPE_VALEN / "verify_export_parity.py"), + "--valen-source", os.environ["VALEN_SRC"], + "--checkpoint", os.environ["VALEN_CKPT"], + "--base", os.environ["VALEN_BASE"], + "--export", str(export_dir), + "--device", "cuda", + ], + capture_output=True, + text=True, + timeout=3600, + ) + assert result.returncode in (0, 1), result.stdout[-4000:] + result.stderr[-4000:] + + manifest = exporter.validate_export(export_dir) + parity = manifest["parity"] + assert parity["device"].startswith("cuda") + assert parity["tolerance"] == 1e-6 + assert {case["case"] for case in parity["cases"]} == {"text", "image"} + # The pinned artifacts keep agreeing on the discrete answers. + assert parity["exact_answer_agreement"] is True + assert all(case["exact_answer_agreement"] for case in parity["cases"]) + # The recorded verdict is internally consistent with the measurements. + assert parity["max_probability_delta"] == max( + case["max_probability_delta"] for case in parity["cases"] + ) + expected_status = ( + "passed" + if all(case["passed"] for case in parity["cases"]) + else "failed" + ) + assert parity["status"] == expected_status + for case in parity["cases"]: + case_passes = ( + case["exact_answer_agreement"] + and case["max_probability_delta"] <= parity["tolerance"] + ) + assert case["passed"] is case_passes + if not case_passes: + assert any("tolerance" in problem for problem in case["problems"]) + assert parity["merge_drift"]["modules"] == 186 + aggregate = parity["merge_drift"]["aggregate"] + assert aggregate["modules"] == 186 + assert 0.0 < aggregate["max_abs_diff"] + assert 0.0 < aggregate["max_ulp_ratio"] <= 1.0 From 7c53b98be139bc81655d6093e686dc2f6fa33e61 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Wed, 14 Oct 2026 23:50:43 +0800 Subject: [PATCH 11/32] valen: document the checkpoint export and wire its recipe regression Add recipe/valen/export.md (usage, artifact inventory, manifest schema, merge decision, downcasts, head, consumption, pins/licenses, parity pointer) and recipe/valen/test_export_reference.py, which chains the tests/valen/test_export.py battery and the env-gated real-artifact determinism gate and validates an exported set through the tool's own validate_export. Link the export from recipe/README.md, src/models/valen/README.md and one line in reference.md. --- recipe/README.md | 3 + recipe/valen/export.md | 192 ++++++++++++++++++++++++++ recipe/valen/reference.md | 3 + recipe/valen/test_export_reference.py | 140 +++++++++++++++++++ src/models/valen/README.md | 8 ++ 5 files changed, 346 insertions(+) create mode 100644 recipe/valen/export.md create mode 100644 recipe/valen/test_export_reference.py diff --git a/recipe/README.md b/recipe/README.md index aeba1508..96afe47f 100644 --- a/recipe/README.md +++ b/recipe/README.md @@ -14,6 +14,9 @@ For a first real decision, follow the [complete CPU walkthrough](../docs/getting - [Valen-Preview-0923 reference worker](valen/reference.md): prepare the pinned Preview checkpoint and Qwen base, start the text-or-image `choice`/`noul`/`score` worker and verify the frontend. +- [Valen-Preview-0923 checkpoint export](valen/export.md): export the pinned + checkpoint and base to a manifest-described safetensors artifact set and run + the export regression. - [Cua-S1 4B 0.2 native text worker](cua_s1/native.md): build the CUDA library and the Rust worker, export the merged weights and start the worker. - [Open-Jev native text worker](open_jev/native.md): export the merged text diff --git a/recipe/valen/export.md b/recipe/valen/export.md new file mode 100644 index 00000000..abb738f9 --- /dev/null +++ b/recipe/valen/export.md @@ -0,0 +1,192 @@ +# Valen-Preview-0923 checkpoint export + +This recipe turns the pinned Preview checkpoint plus its pinned Qwen3.5-2B +base into a safetensors artifact set that any consumer — native or +otherwise — can load without the training-framework pickle or the reference +Python code. One deterministic command performs every transformation and +records each one in a JSON manifest, so the artifact set can be audited on +its own. It implements spec 02 (checkpoint export with parity evidence); +serving the export is spec 03 and out of scope here. + +The export complements the +[reference worker recipe](reference.md): the worker serves through the +pinned Python reference stack, while the export is the path for everything +that consumes weights directly. + +## Usage + +Requirements: the pinned artifacts prepared exactly as in the reference +recipe's [install section](reference.md#install-and-prepare-the-artifacts), +and the repository venv. The export runs CPU-only and never needs a GPU. + +~~~sh +PYTHONPATH=src .venv/bin/python recipe/valen/export_checkpoint.py \ + --checkpoint "$VALEN_CKPT" \ + --base "$VALEN_BASE" \ + --out weights/valen-export +~~~ + +`--out` names the output directory; it must not exist (or be empty), and +every path is overridable this way. The export streams tensor-by-tensor +(mmap reads, an incremental safetensors writer), so host memory stays well +below the ~4.5 GiB artifact size. Expected console output: + +~~~text +exported weights/valen-export + backbone: 392 tensors + decision-head: 2 tensors + lora-merged: 186 tensors + merger: 6 tensors + vision-top: 48 tensors + downcasts: 36 fp32 -> bf16 + manifest: weights/valen-export/valen_export.json +~~~ + +Before writing anything, the export enforces the artifact pins — the +checkpoint SHA-256 against `training_metadata.json`, the base weights +digest against both `valen_manifest.json` and the checkpoint's own +`base_manifest`, and the revision agreement between them — and rejects a +checkpoint whose `stage` is not `vision_top`. A mismatch fails loudly +instead of exporting the wrong weights. + +## The artifact set + +Four files, nothing else: + +| File | Contents | +| --- | --- | +| `model.safetensors` | The backbone in the base's own key layout (`model.language_model.*` / `model.visual.*`), BF16 only: 632 base tensors with the language LoRA merged (186), the trained vision-top blocks 20–23 and the merger substituted as plain replacements (48 + 6), the remaining 392 passing through (36 of them downcast from FP32). | +| `head.safetensors` | The decision head: two FP32 matrices `head.decision.weight` and `head.candidate.weight`, no bias. | +| `config.json` | The base config copied verbatim, so the reference builder loads the export unchanged. | +| `valen_export.json` | The manifest (below). | + +Inventory, totaling 634 manifest tensor records: 392 `backbone`, 186 +`lora-merged`, 48 `vision-top`, 6 `merger`, 2 `decision-head`. The +vision-top blocks and merger land in the base's own `model.visual.*` +layout — same key tails and shapes as the base snapshot — so they are +drop-in replacements. + +## The manifest + +`valen_export.json` is format `valen-checkpoint-export/1` and is the +contract for auditing the export without the reference code. Its +top-level structure: + +~~~text +format "valen-checkpoint-export/1" +sources pinned checkpoint and base revisions + SHA-256s, tool versions +merge the merge decision, formula, scaling, counts, fallback clause +downcasts the names of every FP32 -> BF16 downcast tensor +head head file, dtype, projection_dim, hidden_size, formulas +tensors per-tensor name/shape/dtype/component/source/file records +files size + SHA-256 of each artifact file +consumption how to load the artifact set (transcribed below) +parity the parity-gate result; null until the gate fills it +parity_slot_shape the documented shape of the parity record +~~~ + +`export_checkpoint.validate_export` cross-checks a finished export +against its manifest — file digests, tensor inventory against the +safetensors headers, downcast bookkeeping, head config — reading headers +and digests only, so it also runs cheaply against the multi-GiB real +artifact set. + +## The merge decision + +The export merges the language LoRA into the backbone rather than +carrying adapters separately, because the native text path loads merged +weights only. The merge is `bf16(fp32(W) + 2·(B@A))` per targeted weight: +the delta `scaling · (B @ A)` is computed in FP32, added to the +FP32-upcast base weight, and the sum is rounded to BF16 exactly once — +bit-equal to peft 0.18.1's `Linear.merge` at the reference's cast +boundaries. The pinned config's scaling is α/r = 64/32 = 2.0 over 372 +LoRA keys targeting 186 language linears. + +The fallback, recorded in the manifest's merge block, is to export the +FP32 LoRA separately and serve unmerged — a mode that triggers only if +the parity gate fails (spec 02, user story 9). It is deliberately not +implemented while the gate passes. + +## The FP32 downcasts + +The base stores 36 tensors in FP32 — the delta-rule normalizer logs and +norms, `model.language_model.layers..linear_attn.A_log` and +`...linear_attn.norm.weight` — and the export downcasts each to BF16, +listing every one in the manifest's `downcasts` block. The downcast is +lossless against the reference's own BF16 runtime: transformers has no +`keep_in_fp32_modules` entry for this architecture and upcasts these +tensors with `.float()` at use, so the reference already computes on the +BF16-rounded values. Any FP32 base tensor outside the declared families +is an error, not a silent downcast. + +## The decision head + +`head.safetensors` holds the two FP32 projections — +`head.decision.weight` and `head.candidate.weight` — each +256 × 2048 (`projection_dim` × `hidden_size`), no bias, with the +dimensions and formulas recorded in the manifest's head block: +logits are `(W_cand @ h_cand) . (W_dec @ h_dec) / sqrt(projection_dim)` +in FP32. The head is exported in FP32, unchanged from the checkpoint, +which stores it in FP32. + +## Consumption + +The manifest's `consumption` block records how to load the export; in +full: + +~~~text +backbone: Qwen3_5ForConditionalGeneration.from_pretrained(out, dtype=bf16, + attn_implementation='eager', local_files_only=True).model +head: head.safetensors: DecisionHead(hidden_size=2048, + projection_dim=256), fp32, no bias +~~~ + +The copied `config.json` makes the `from_pretrained` call work against +the export directory directly; the head is rebuilt from its two matrices +and recorded dimensions. Nothing else is needed to reconstruct the model. + +## Pins and licenses + +The export consumes the same pinned artifacts as the +[reference worker](reference.md#pinned-artifacts) — the Preview +checkpoint revision `81b9c639...` (SHA-256 +`836622ef...f42f5a012`) and the Qwen3.5-2B base revision +`15852e8c...` — and records both, with per-file digests, in the +manifest's `sources` block on every run. The Valen source, the Preview +checkpoint, and the Qwen base report Apache-2.0 license metadata for +these pins (recorded with the serving slice in +[PR #115](https://github.com/ThinkFlowLab/system1-omni/pull/115)); +re-check the metadata when changing a revision. The exported artifact +set inherits those terms. + +## Parity evidence + +Whether the exported stack reproduces the original checkpoint's answers +is the parity gate's question, not this document's claim: the verdict — +numbers, device, tolerances, merge drift — lives in the manifest's +`parity` slot (left `null` by the export, shaped by +`parity_slot_shape`) and in the evidence blocks of +[reference.md](reference.md), where the parity run is recorded. Consult +those for the result; do not treat an export as parity-proven until its +manifest's parity slot is filled. + +## Determinism and regression + +Two runs on the same inputs produce byte-identical artifacts and +identical manifests: no timestamps, no absolute paths, tensors and JSON +written in sorted key order, single-threaded CPU math. The regression is +wired into the recipe-test pattern: + +~~~sh +PYTHONPATH=src .venv/bin/python -m pytest tests/valen -q +python recipe/valen/test_export_reference.py +~~~ + +The first is the CPU-only suite (71 tests passed, 1 artifact-gated skip at +the time of writing). The second chains that suite's export battery plus +the env-gated real-artifact determinism gate — set `VALEN_CKPT` and +`VALEN_BASE` to run it; it exports twice and compares every byte — and, +with `VALEN_EXPORT` pointing at an exported directory (defaulting to +`weights/valen-export` when present), validates the finished artifact set +against its manifest. Without the artifacts, both gated phases print a +clear skip line and the script still passes. diff --git a/recipe/valen/reference.md b/recipe/valen/reference.md index 6475f1e0..defdc0cd 100644 --- a/recipe/valen/reference.md +++ b/recipe/valen/reference.md @@ -176,6 +176,9 @@ print(manifest["versions"]) PY ~~~ +The pinned checkpoint and base can also be exported to a manifest-described +safetensors artifact set for non-Python consumers: [export.md](export.md). + ## Start the worker The worker loads all model-owned state and performs a real representative diff --git a/recipe/valen/test_export_reference.py b/recipe/valen/test_export_reference.py new file mode 100644 index 00000000..680bf0b9 --- /dev/null +++ b/recipe/valen/test_export_reference.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Regression wiring for the Valen checkpoint export (spec 02, E3). + +Chains the export contract tests in ``tests/valen/test_export.py`` — the +CPU-only battery plus the env-gated real-artifact determinism gate — and +validates an exported artifact set through the export tool's own +``validate_export``, without duplicating a single assertion: every check +lives in the test module or the tool, and this script only orchestrates. + + python recipe/valen/test_export_reference.py + +Without the pinned artifacts it still runs the CPU battery and prints a +clear skip line for each artifact-gated phase, exiting 0. With +``VALEN_CKPT`` and ``VALEN_BASE`` set (see [reference.md](reference.md), +"Pinned artifacts") the determinism gate runs too, exporting the real +checkpoint twice and comparing every byte. With ``VALEN_EXPORT`` set — or +a ``weights/valen-export`` directory next to the repository root — the +finished artifact set is validated against its manifest. See +[export.md](export.md) for the export itself. +""" + +from __future__ import annotations + +import os +import sys +import tempfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO_ROOT = HERE.parents[1] +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(REPO_ROOT / "tests" / "valen")) + +import export_checkpoint as tool # noqa: E402 +import test_export as contract # noqa: E402 + + +def contract_tests(): + """The export contract battery from tests/valen/test_export.py, in module + order; ``tmp_path``-taking tests get a fresh directory each.""" + + tests = [] + for name in sorted(dir(contract)): + if not name.startswith("test_"): + continue + function = getattr(contract, name) + takes_tmp_path = "tmp_path" in function.__code__.co_varnames + tests.append((name, function, takes_tmp_path)) + return tests + + +def run_contract_battery(results): + """Run every test in tests/valen/test_export.py directly. + + A test that raises pytest's ``Skipped`` outcome (the real-artifact gate + without ``VALEN_CKPT``/``VALEN_BASE``) is reported as a skip with the + test's own reason, never as a failure. + """ + + for name, function, takes_tmp_path in contract_tests(): + try: + if takes_tmp_path: + with tempfile.TemporaryDirectory() as tmp: + function(Path(tmp)) + else: + function() + except BaseException as error: # noqa: B036 - classified below + if type(error).__name__ == "Skipped": + record(results, name, "skip", str(error)) + else: + record(results, name, False, f"{type(error).__name__}: {error}") + else: + record(results, name, True) + + +def validate_artifact_set(results, export_dir): + """Validate an exported directory through the tool's own cross-check.""" + + try: + manifest = tool.validate_export(Path(export_dir)) + except Exception as error: + record(results, f"validate {export_dir}", False, str(error)) + return + counts = {} + for entry in manifest["tensors"]: + counts[entry["component"]] = counts.get(entry["component"], 0) + 1 + parity = manifest["parity"] + summary = ( + f"{manifest['format']}, {len(manifest['tensors'])} tensors " + + ", ".join(f"{counts[c]} {c}" for c in sorted(counts)) + + f", {len(manifest['downcasts'])} fp32->bf16 downcasts" + + f", merge {manifest['merge']['decision']}" + + f", parity slot {'filled' if parity is not None else 'not yet recorded (E2)'}" + ) + record(results, f"validate {export_dir}", True, summary) + + +def record(results, name, outcome, detail=""): + results.append((name, outcome, detail)) + + +def main() -> None: + results: list[tuple[str, object, str]] = [] + + run_contract_battery(results) + + export_dir = os.environ.get("VALEN_EXPORT") + if export_dir: + validate_artifact_set(results, export_dir) + elif (REPO_ROOT / "weights" / "valen-export").is_dir(): + validate_artifact_set(results, REPO_ROOT / "weights" / "valen-export") + else: + record( + results, + "validate exported artifact set", + "skip", + "set VALEN_EXPORT to an exported directory (the export's default " + "output is weights/valen-export) to validate it against its manifest", + ) + + failed = skipped = 0 + for name, outcome, detail in results: + if outcome is True: + print(f" ok {name}" + (f" — {detail}" if detail else "")) + elif outcome == "skip": + skipped += 1 + print(f" skip {name} — {detail}") + else: + failed += 1 + print(f" FAIL {name}") + print(" " + detail.strip().replace("\n", "\n ")[:400]) + verdict = "export_reference tests: ok" if not failed else ( + f"export_reference tests: {failed} FAILED" + ) + print(verdict + (f" ({skipped} skipped)" if skipped else "")) + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main() diff --git a/src/models/valen/README.md b/src/models/valen/README.md index d94585ed..4416ae4f 100644 --- a/src/models/valen/README.md +++ b/src/models/valen/README.md @@ -169,6 +169,14 @@ The status is `422` when a well-formed request cannot be evaluated: Any other failure is `500` with a generic detail and no request content in the log (the exception type only). +## Checkpoint export + +The pinned checkpoint and base also export to a native-loadable +safetensors artifact set — LoRA merged, vision-top and merger replaced, +FP32 decision head — with every transformation recorded in a JSON +manifest: see [recipe/valen/export.md](../../../recipe/valen/export.md). +Consuming that export is a later slice and does not change this worker. + ## Ownership boundary - `protocol.py` owns wire validation and safe inline-image limits. From b616097980d437069de9e0689fb95919f423a864 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 00:02:24 +0800 Subject: [PATCH 12/32] valen: record the export-parity verdict and drift evidence Append the spec-02 export-parity block to recipe/valen/reference.md: the two serially loaded stacks (original ValenExecutor build vs the exported artifact set's documented consumption path), the verbatim gate output on the RTX 5060 host (text delta 7.06e-04, image delta 1.73e-02, exact answer agreement on both fixtures), the 186-module merge-drift aggregate (max one BF16 ulp per adapted module, ~53% bit-exact, worst relative drift 7.6e-3), the control checks (same-stack bit-identical re-runs, original stack matching the committed serving records, exported tensors bit-checked against the merge math), and the honest verdict: the declared 1e-6 tolerance fails, so the spec-02 fallback (separate FP32 LoRA export, unmerged serving) is triggered; the merged set keeps exact discrete answers and parity.status = failed in the manifest. --- recipe/valen/reference.md | 96 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/recipe/valen/reference.md b/recipe/valen/reference.md index defdc0cd..60355f46 100644 --- a/recipe/valen/reference.md +++ b/recipe/valen/reference.md @@ -458,6 +458,102 @@ and [`example-score-request.json`](example-score-request.json) unless one model load. Both blocks above are verbatim output of the committed tool; the `noul` and `score` blocks are recorded in the integration pull request. +## Export parity (spec 02) + +The exported artifact set ([`export.md`](export.md)) was compared against the +original pinned checkpoint stack with +[`verify_export_parity.py`](verify_export_parity.py) on the same host as the +records above. The two stacks are loaded serially, each in its own subprocess +(the ~6.6 GiB BF16 stack does not fit twice in 8 GiB VRAM): + +- original: the worker's own `ValenExecutor` build — pinned base, + PEFT-wrapped language model with the FP32 LoRA applied unmerged, trained + vision-top blocks, merger and head from `checkpoint.pt`; +- exported: the consumption path the export manifest documents — + `Qwen3_5ForConditionalGeneration.from_pretrained(weights/valen-export, + dtype=bf16, eager).model` plus the decision head rebuilt from + `head.safetensors` (DecisionHead 2048→256, FP32, no bias), run through the + pinned `Valen` class. + +Both stacks answer the text example and the image fixture compiled by the +pinned `Compiler`; the declared gate is exact answer agreement with +probability deltas ≤ 1e-6. The block below is the verbatim tool output +(device `cuda:0`, NVIDIA GeForce RTX 5060 Laptop GPU, sm_120, 7.96 GiB, +torch 2.11.0+cu128; recorded 2026-10-14): + +~~~text +case: text +logical tokens: 48 +compute tokens: 48 +max probability delta: 7.0580840111e-04 +answer agreement: PASS +probability tolerance <= 1e-06: FAIL +token accounting: PASS +problem: probability delta 7.058084e-04 exceeds tolerance 1e-06 + +case: image +logical tokens: 108 +compute tokens: 108 +max probability delta: 1.7260342836e-02 +answer agreement: PASS +probability tolerance <= 1e-06: FAIL +token accounting: PASS +problem: probability delta 1.726034e-02 exceeds tolerance 1e-06 + +merge drift: 186 adapted modules (probe 256x bf16 rows, seed 0, scaling 2, cpu single thread) + max output delta: 3.125000e-02 (mean 2.024530e-02), worst model.language_model.layers.0.linear_attn.in_proj_a.weight + max relative output drift: 7.575758e-03 (mean 4.521309e-03), worst model.language_model.layers.12.linear_attn.in_proj_b.weight + max ulp ratio: 1, mean bit-exact fraction: 0.530288 +~~~ + +Per-candidate probabilities (original stack → exported stack): + +~~~text +text/refund: 0.5159630775 0.4840368927 -> 0.5152572991 0.4847427009 (both choose refund) +image/color: 0.4459854066 0.5540145636 -> 0.4632457486 0.5367543097 (both choose black) +~~~ + +Verdict: **the parity gate fails on the declared 1e-6 tolerance**. The +discrete answers agree exactly on both fixtures and the token accounting is +identical, but the merged export's probabilities drift by 7.1e-4 (text) and +1.7e-2 (image) — the merge drift accumulating through the 24 adapted layers, +three orders of magnitude above the tolerance. The drift measurement itself +is bounded at one BF16 ulp per adapted module (max ulp ratio exactly 1, ~53% +of probe activations bit-exact), matching the research-001 per-layer +estimate; the failure is the accumulation, not any single module. + +Control checks recorded with the run: + +- each stack was loaded twice in separate subprocesses and produced + bit-identical answers, so the cross-stack delta above is fully systematic + (the weight transformation), not run-to-run noise; +- the original stack's probabilities match this recipe's committed serving + records (0.5159631044… / 0.5540145914…) within the 2.7e-8 worker-reference + tolerance recorded above, confirming the comparison's reference side is + the served model; +- every exported tensor was cross-checked bit-exactly against the export + tool's merge math on the given base and checkpoint before the drift run. + +Per spec 02 user story 9, the failed gate triggers the documented fallback: +export the FP32 LoRA separately and serve unmerged (recorded in the +manifest's `merge.fallback`), which is the only path that meets the +reference-serving tolerance. The merged artifact set keeps exact discrete +answers and is recorded with `parity.status = "failed"` in +`weights/valen-export/valen_export.json`, together with the per-case +deltas, device, tolerances and the drift aggregate. + +Reproduce (the export itself per [`export.md`](export.md) first): + +~~~sh +PYTHONPATH=src .venv/bin/python recipe/valen/verify_export_parity.py \ + --valen-source "$VALEN_SRC" --checkpoint "$VALEN_CKPT" \ + --base "$VALEN_BASE" --export weights/valen-export --device cuda +~~~ + +The script validates the artifact set and its recorded source pins before +loading, refuses to overwrite an existing manifest parity record unless +`--force` is given, and exits 0 only when the gate passes. + ## Troubleshooting and limits - The source checkout must be exactly the pinned commit. From e1153d55267a160f8b6ee7873997da06d4d79ac0 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 00:28:11 +0800 Subject: [PATCH 13/32] valen: stream validate_export file digests instead of reading whole files The manifest cross-check read every artifact whole (read_bytes), pulling the ~4.5 GiB model.safetensors into host memory and contradicting the documented headers-and-digests-only contract. Sizes now come from stat() and digests from the module's streaming sha256_file, and the per-tensor safetensors header check reuses one handle per file instead of reopening per tensor. --- recipe/valen/export_checkpoint.py | 49 +++++++++++++++++++------------ tests/valen/test_export.py | 16 ++++++++++ 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/recipe/valen/export_checkpoint.py b/recipe/valen/export_checkpoint.py index 90961949..260d56f4 100644 --- a/recipe/valen/export_checkpoint.py +++ b/recipe/valen/export_checkpoint.py @@ -629,12 +629,15 @@ def validate_export(out_dir): f"{sorted(artifact_files)}" ) - # Recorded file digests must match the bytes on disk. + # Recorded file digests must match the bytes on disk. Digests are + # streamed (sha256_file) and sizes come from stat(), so the multi-GiB + # model.safetensors never enters host memory. for name, record in sorted(manifest["files"].items()): - data = (out_dir / name).read_bytes() - if record["size"] != len(data): - raise ValueError(f"{name} is {len(data)} bytes, manifest says {record['size']}") - digest = hashlib.sha256(data).hexdigest() + path = out_dir / name + size = path.stat().st_size + if record["size"] != size: + raise ValueError(f"{name} is {size} bytes, manifest says {record['size']}") + digest = sha256_file(path) if record["sha256"] != digest: raise ValueError(f"{name} sha256 {digest} != manifest {record['sha256']}") @@ -654,9 +657,16 @@ def validate_export(out_dir): raise ValueError(f"manifest lists {entry['name']!r} twice") listed[(entry["file"], entry["name"])] = entry on_disk = set() - for file_name in (BACKBONE_FILE, HEAD_FILE): - with safe_open(out_dir / file_name, framework="pt") as handle: + handles = { + file_name: safe_open(out_dir / file_name, framework="pt") + for file_name in (BACKBONE_FILE, HEAD_FILE) + } + try: + for file_name, handle in handles.items(): on_disk.update((file_name, key) for key in handle.keys()) + finally: + for handle in handles.values(): + handle.__exit__(None, None, None) orphans = sorted(set(listed) - on_disk) if orphans: raise ValueError(f"manifest lists tensors absent from the files: {orphans[:4]}") @@ -666,19 +676,20 @@ def validate_export(out_dir): f"{unlisted[0][0]} holds unlisted tensor {unlisted[0][1]!r}" f" ({len(unlisted)} total)" ) - for file_name, key in sorted(on_disk): + for file_name in (BACKBONE_FILE, HEAD_FILE): with safe_open(out_dir / file_name, framework="pt") as handle: - slice_ = handle.get_slice(key) - entry = listed[(file_name, key)] - if entry["shape"] != list(slice_.get_shape()): - raise ValueError( - f"{key!r} shape {entry['shape']} != file {list(slice_.get_shape())}" - ) - if entry["dtype"] != _MANIFEST_DTYPES[slice_.get_dtype()]: - raise ValueError( - f"{key!r} dtype {entry['dtype']} != file " - f"{_MANIFEST_DTYPES[slice_.get_dtype()]}" - ) + for key in sorted(k for f, k in on_disk if f == file_name): + slice_ = handle.get_slice(key) + entry = listed[(file_name, key)] + if entry["shape"] != list(slice_.get_shape()): + raise ValueError( + f"{key!r} shape {entry['shape']} != file {list(slice_.get_shape())}" + ) + if entry["dtype"] != _MANIFEST_DTYPES[slice_.get_dtype()]: + raise ValueError( + f"{key!r} dtype {entry['dtype']} != file " + f"{_MANIFEST_DTYPES[slice_.get_dtype()]}" + ) # Downcast bookkeeping: the list and the per-tensor flags must agree. downcasts = set(manifest["downcasts"]) diff --git a/tests/valen/test_export.py b/tests/valen/test_export.py index 1514562d..d46bd551 100644 --- a/tests/valen/test_export.py +++ b/tests/valen/test_export.py @@ -559,6 +559,22 @@ def test_validate_export_accepts_a_fresh_export(tmp_path): assert manifest["format"] == tool.FORMAT +def test_validate_export_streams_file_digests(tmp_path, monkeypatch): + """File digests must be computed streaming, never via a whole-file read: + the real model.safetensors is ~4.5 GiB and the contract is "headers and + digests only" (validate_export docstring, export.md).""" + + out = tmp_path / "export" + run_tiny_export(out, tmp_path / "in-a") + + def refuse(self): + raise AssertionError(f"validate_export read {self} whole into memory") + + monkeypatch.setattr(Path, "read_bytes", refuse) + manifest = tool.validate_export(out) + assert manifest["format"] == tool.FORMAT + + def test_validate_export_detects_tampered_bytes(tmp_path): import pytest From 9294316c6ac5ed82493d110079ad98e95078e465 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 00:28:17 +0800 Subject: [PATCH 14/32] valen: fail closed on missing checkpoint and base digest pins A missing training_metadata.json silently skipped the checkpoint digest check right before a weights_only=False unpickle, and the base weights pins were conditional on both sides declaring a digest. Following the worker precedent (engine.py pins unconditionally), a missing training_metadata.json or an undeclared base digest is now an error; the doc's pin paragraph says so. --- recipe/valen/export.md | 4 +- recipe/valen/export_checkpoint.py | 26 ++++-- tests/valen/test_export.py | 147 ++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 11 deletions(-) diff --git a/recipe/valen/export.md b/recipe/valen/export.md index abb738f9..184b62c8 100644 --- a/recipe/valen/export.md +++ b/recipe/valen/export.md @@ -46,8 +46,8 @@ Before writing anything, the export enforces the artifact pins — the checkpoint SHA-256 against `training_metadata.json`, the base weights digest against both `valen_manifest.json` and the checkpoint's own `base_manifest`, and the revision agreement between them — and rejects a -checkpoint whose `stage` is not `vision_top`. A mismatch fails loudly -instead of exporting the wrong weights. +checkpoint whose `stage` is not `vision_top`. A missing pin or a mismatch +fails loudly instead of exporting the wrong weights. ## The artifact set diff --git a/recipe/valen/export_checkpoint.py b/recipe/valen/export_checkpoint.py index 260d56f4..b5c38268 100644 --- a/recipe/valen/export_checkpoint.py +++ b/recipe/valen/export_checkpoint.py @@ -807,12 +807,17 @@ def export(checkpoint_dir, base_dir, out_dir): checkpoint_path = checkpoint_dir / CHECKPOINT_FILE checkpoint_sha = sha256_file(checkpoint_path) metadata_path = checkpoint_dir / TRAINING_METADATA_FILE - if metadata_path.is_file(): - declared = json.loads(metadata_path.read_text(encoding="utf-8"))["checkpoint_sha256"] - if declared != checkpoint_sha: - raise ValueError( - f"checkpoint sha256 {checkpoint_sha} != training_metadata {declared}" - ) + if not metadata_path.is_file(): + raise ValueError( + f"checkpoint directory {checkpoint_dir} is missing " + f"{TRAINING_METADATA_FILE}: the checkpoint digest cannot be pinned " + "before the pickle is unpickled (the worker pins unconditionally)" + ) + declared = json.loads(metadata_path.read_text(encoding="utf-8"))["checkpoint_sha256"] + if declared != checkpoint_sha: + raise ValueError( + f"checkpoint sha256 {checkpoint_sha} != training_metadata {declared}" + ) payload = torch.load( checkpoint_path, map_location="cpu", weights_only=False, mmap=True @@ -834,9 +839,12 @@ def export(checkpoint_dir, base_dir, out_dir): ("valen_manifest.json", valen_manifest.get("weight_sha256")), ("checkpoint base_manifest", base_manifest.get("weight_sha256")), ): - # Both pins record a per-shard sha mapping; they must cover exactly - # the shards on disk and match every digest. - if declared is not None and dict(declared) != shard_sums: + # Both pins must declare a per-shard sha mapping covering exactly the + # shards on disk and matching every digest; a missing declaration is + # a pin bypass, not a pass. + if declared is None: + raise ValueError(f"{label} does not declare the base weights sha256") + if dict(declared) != shard_sums: raise ValueError( f"base weights sha256 {shard_sums} != {label} {dict(declared)}" ) diff --git a/tests/valen/test_export.py b/tests/valen/test_export.py index d46bd551..221eb702 100644 --- a/tests/valen/test_export.py +++ b/tests/valen/test_export.py @@ -683,6 +683,153 @@ def test_checkpoint_revision_from_tree_pin(tmp_path): assert tool.checkpoint_revision(checkpoint) is None +# --- export()'s artifact pins (tiny synthetic checkpoint + base) --- + +def tiny_pin_base_dir(directory): + """A miniature pinned base: one shard, index, config, valen_manifest. + + The manifest declares the shard's real digest, so the pin stage passes + unless a test tampers with it.""" + + import hashlib + + directory = Path(directory) + directory.mkdir(parents=True) + shard = directory / "model.safetensors-00001-of-00001.safetensors" + shard.write_bytes(b"shard-bytes") + (directory / tool.BASE_INDEX_FILE).write_text( + json.dumps({"weight_map": {"a": shard.name}}), encoding="utf-8" + ) + (directory / "config.json").write_text( + '{"text_config": {"hidden_size": 6}}', encoding="utf-8" + ) + (directory / tool.BASE_MANIFEST_FILE).write_text( + json.dumps( + { + "repo": "Qwen/Qwen3.5-2B", + "revision": "badbee", + "weight_sha256": { + shard.name: hashlib.sha256(b"shard-bytes").hexdigest() + }, + } + ), + encoding="utf-8", + ) + return directory + + +def tiny_pin_checkpoint(directory, *, base_manifest=None, checkpoint_sha=None): + """Save a miniature loadable checkpoint.pt plus its training_metadata.""" + + directory = Path(directory) + directory.mkdir(parents=True, exist_ok=True) + import torch + + payload = { + "config": { + "stage": tool.PINNED_STAGE, + "lora_rank": 4, + "lora_alpha": 8, + "projection_dim": 3, + }, + "weights": {}, + "base_manifest": base_manifest + if base_manifest is not None + else { + "repo": "Qwen/Qwen3.5-2B", + "revision": "badbee", + "weight_sha256": {}, + }, + } + checkpoint_path = directory / tool.CHECKPOINT_FILE + torch.save(payload, checkpoint_path) + real_sha = tool.sha256_file(checkpoint_path) + (directory / tool.TRAINING_METADATA_FILE).write_text( + json.dumps({"checkpoint_sha256": checkpoint_sha or real_sha}), encoding="utf-8" + ) + return directory, real_sha + + +def test_export_requires_training_metadata_pin(tmp_path): + """A checkpoint directory without training_metadata.json must be rejected: + the digest pin has to be verified before the pickle is ever unpickled + (the worker precedent: src/models/valen/engine.py pins unconditionally).""" + + import pytest + + checkpoint, _ = tiny_pin_checkpoint(tmp_path / "ckpt") + (checkpoint / tool.TRAINING_METADATA_FILE).unlink() + with pytest.raises(ValueError, match="training_metadata.json"): + tool.export(checkpoint, tiny_pin_base_dir(tmp_path / "base"), tmp_path / "out") + + +def test_export_rejects_checkpoint_sha_mismatch(tmp_path): + import pytest + + checkpoint, _ = tiny_pin_checkpoint(tmp_path / "ckpt", checkpoint_sha="00" * 32) + with pytest.raises(ValueError, match="training_metadata"): + tool.export(checkpoint, tiny_pin_base_dir(tmp_path / "base"), tmp_path / "out") + + +def test_export_requires_valen_manifest_base_digest(tmp_path): + """valen_manifest.json must declare the base weights digest; a missing + declaration is a pin bypass, not a pass.""" + + import pytest + + checkpoint, _ = tiny_pin_checkpoint(tmp_path / "ckpt") + base = tiny_pin_base_dir(tmp_path / "base") + manifest = json.loads((base / tool.BASE_MANIFEST_FILE).read_text(encoding="utf-8")) + del manifest["weight_sha256"] + (base / tool.BASE_MANIFEST_FILE).write_text(json.dumps(manifest), encoding="utf-8") + with pytest.raises(ValueError, match="valen_manifest.json"): + tool.export(checkpoint, base, tmp_path / "out") + + +def test_export_requires_checkpoint_base_manifest_digest(tmp_path): + """The checkpoint's own base_manifest must declare the base weights digest + too; absence must fail the pin instead of skipping half of it.""" + + import pytest + + checkpoint, _ = tiny_pin_checkpoint( + tmp_path / "ckpt", base_manifest={"revision": "badbee"} + ) + base = tiny_pin_base_dir(tmp_path / "base") + with pytest.raises(ValueError, match="checkpoint base_manifest"): + tool.export(checkpoint, base, tmp_path / "out") + + +def test_export_rejects_declared_base_digest_mismatch(tmp_path): + import pytest + + checkpoint, _ = tiny_pin_checkpoint( + tmp_path / "ckpt", + base_manifest={"revision": "badbee", "weight_sha256": {"wrong.safetensors": "ff" * 32}}, + ) + with pytest.raises(ValueError, match="checkpoint base_manifest"): + tool.export(checkpoint, tiny_pin_base_dir(tmp_path / "base"), tmp_path / "out") + + +def test_export_pin_stage_passes_on_consistent_pins(tmp_path): + """Sanity: with every pin present and consistent, the pin stage completes + and the run proceeds into the inventory check (the tiny payload cannot + satisfy the pinned 428-tensor inventory).""" + + import pytest + + base = tiny_pin_base_dir(tmp_path / "base") + declared = json.loads( + (base / tool.BASE_MANIFEST_FILE).read_text(encoding="utf-8") + )["weight_sha256"] + checkpoint, _ = tiny_pin_checkpoint( + tmp_path / "ckpt", + base_manifest={"revision": "badbee", "weight_sha256": declared}, + ) + with pytest.raises(ValueError, match="inventory"): + tool.export(checkpoint, base, tmp_path / "out") + + # --- the full determinism check against the real pinned artifacts --- # Set VALEN_CKPT and VALEN_BASE (the recipe reference.md artifact exports) to # run; it exports twice and compares every byte, mirroring the CUA_S1_BASE From 4b165ff7b7b1191735409440f2dfc6c01c53397a Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 00:28:17 +0800 Subject: [PATCH 15/32] valen: verify base shard digests in the parity source cross-check verify_sources_match_manifest checked the checkpoint sha and base revision strings but not the base weights digests the manifest records, although 392 tensors pass through the export byte by byte. The gate now re-hashes the base shard against sources.base.weights_sha256; tampered records or swapped shard bytes are rejected before any model loads. --- recipe/valen/verify_export_parity.py | 15 ++++- tests/valen/test_export_parity.py | 92 ++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/recipe/valen/verify_export_parity.py b/recipe/valen/verify_export_parity.py index 158bcae7..297d3633 100644 --- a/recipe/valen/verify_export_parity.py +++ b/recipe/valen/verify_export_parity.py @@ -613,7 +613,12 @@ def parse_args(argv=None): def verify_sources_match_manifest(args, manifest): - """The parity evidence only holds for the artifacts the export recorded.""" + """The parity evidence only holds for the artifacts the export recorded. + + Verifies the checkpoint sha, the base revision and — because 392 tensors + pass through the export byte by byte — the base weights shard digests + themselves against the manifest's recorded ``sources.base.weights_sha256``. + """ checkpoint_sha = exporter.sha256_file(Path(args.checkpoint) / exporter.CHECKPOINT_FILE) recorded = manifest["sources"]["checkpoint"]["sha256"] @@ -631,6 +636,14 @@ def verify_sources_match_manifest(args, manifest): f"the export was built from base revision {revision}, but --base " f"pins {base_manifest.get('revision')}" ) + weights_file = exporter.base_weights_file(Path(args.base)) + shard_sums = {weights_file.name: exporter.sha256_file(weights_file)} + recorded_sums = manifest["sources"]["base"]["weights_sha256"] + if recorded_sums != shard_sums: + raise ValueError( + f"the export was built from base weights sha256 {recorded_sums}, " + f"but --base hashes {shard_sums}" + ) def main(argv=None): diff --git a/tests/valen/test_export_parity.py b/tests/valen/test_export_parity.py index fba88f6e..1be4615b 100644 --- a/tests/valen/test_export_parity.py +++ b/tests/valen/test_export_parity.py @@ -350,6 +350,98 @@ def test_fill_parity_slot_refuses_to_silently_overwrite(tmp_path): ] == 1e-8 +# --- the source-pin cross-check before any model is loaded --- + +def pin_args(checkpoint_dir, base_dir): + import argparse + + return argparse.Namespace(checkpoint=str(checkpoint_dir), base=str(base_dir)) + + +def pin_fixture(tmp_path): + """A tiny checkpoint dir + single-shard base and a manifest recording + their true digests, mirroring the real pinned layout.""" + + import hashlib + + from export_checkpoint import sha256_file + + checkpoint_dir = tmp_path / "ckpt" + checkpoint_dir.mkdir() + checkpoint_file = checkpoint_dir / "checkpoint.pt" + checkpoint_file.write_bytes(b"checkpoint-bytes") + base_dir = tmp_path / "base" + base_dir.mkdir() + shard = base_dir / "model.safetensors-00001-of-00001.safetensors" + shard.write_bytes(b"shard-bytes") + (base_dir / "model.safetensors.index.json").write_text( + json.dumps({"weight_map": {"a": shard.name}}), encoding="utf-8" + ) + (base_dir / "valen_manifest.json").write_text( + json.dumps({"revision": "badbee"}), encoding="utf-8" + ) + manifest = { + "sources": { + "checkpoint": {"sha256": sha256_file(checkpoint_file)}, + "base": { + "revision": "badbee", + "weights_sha256": { + shard.name: hashlib.sha256(b"shard-bytes").hexdigest() + }, + }, + } + } + return pin_args(checkpoint_dir, base_dir), manifest + + +def test_verify_sources_match_manifest_accepts_matching_pins(tmp_path): + args, manifest = pin_fixture(tmp_path) + tool.verify_sources_match_manifest(args, manifest) # no exception + + +def test_verify_sources_rejects_checkpoint_digest_drift(tmp_path): + import pytest + + args, manifest = pin_fixture(tmp_path) + manifest["sources"]["checkpoint"]["sha256"] = "00" * 32 + with pytest.raises(ValueError, match="checkpoint sha256"): + tool.verify_sources_match_manifest(args, manifest) + + +def test_verify_sources_rejects_base_revision_drift(tmp_path): + import pytest + + args, manifest = pin_fixture(tmp_path) + manifest["sources"]["base"]["revision"] = "deadbee" + with pytest.raises(ValueError, match="base revision"): + tool.verify_sources_match_manifest(args, manifest) + + +def test_verify_sources_rejects_tampered_base_shard_digest(tmp_path): + """The base weights themselves must be re-hashed against the manifest's + recorded per-shard digests: 392 tensors pass through the export byte by + byte, so a silently swapped base would poison the parity verdict.""" + + import pytest + + args, manifest = pin_fixture(tmp_path) + manifest["sources"]["base"]["weights_sha256"][ + "model.safetensors-00001-of-00001.safetensors" + ] = "00" * 32 + with pytest.raises(ValueError, match="base weights sha256"): + tool.verify_sources_match_manifest(args, manifest) + + +def test_verify_sources_rejects_swapped_base_shard_bytes(tmp_path): + import pytest + + args, manifest = pin_fixture(tmp_path) + shard = tmp_path / "base" / "model.safetensors-00001-of-00001.safetensors" + shard.write_bytes(b"different-shard-bytes") # digest record stays honest + with pytest.raises(ValueError, match="base weights sha256"): + tool.verify_sources_match_manifest(args, manifest) + + # --- the full parity gate against the real pinned artifacts --- # Set VALEN_CKPT, VALEN_BASE and VALEN_SRC (the recipe reference.md artifact # exports) on a CUDA host to run: it exports a fresh artifact set, then runs From 97ffa11f7002eb26de328cab71ba626795a1b213 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 00:28:26 +0800 Subject: [PATCH 16/32] valen: tighten the export audit edges - validate_export counts every directory entry, so a stray subdirectory no longer bypasses the exactly-four-files audit; - run_export cleans up its partial output on failure (removing a directory it created), so a failed run never blocks the retry; - classify_checkpoint_weights rejects vision blocks outside 20-23, not only sub-20 blocks, matching the pinned vision_top inventory; - drop the unused BACKBONE_PREFIX/VISUAL_PREFIX constants and the unused cuda mem_get_info free byte. --- recipe/valen/export_checkpoint.py | 49 +++++++++++++-- recipe/valen/verify_export_parity.py | 2 +- tests/valen/test_export.py | 92 +++++++++++++++++++++++++++- 3 files changed, 136 insertions(+), 7 deletions(-) diff --git a/recipe/valen/export_checkpoint.py b/recipe/valen/export_checkpoint.py index b5c38268..00099698 100644 --- a/recipe/valen/export_checkpoint.py +++ b/recipe/valen/export_checkpoint.py @@ -61,8 +61,6 @@ PEFT_LANGUAGE_PREFIX = "backbone.language_model.base_model.model." PEFT_LORA_SUFFIX_A = ".lora_A.default.weight" PEFT_LORA_SUFFIX_B = ".lora_B.default.weight" -BACKBONE_PREFIX = "model." -VISUAL_PREFIX = "backbone.visual." HEAD_PREFIX = "head." COMPONENT_BACKBONE = "backbone" @@ -197,7 +195,7 @@ def classify_checkpoint_weights(keys): merger.append(("model.visual.merger." + key[len("backbone.visual.merger."):], key)) elif _VISION_BLOCK.match(key): block, tail = _VISION_BLOCK.match(key).groups() - if int(block) < 20: + if int(block) not in (20, 21, 22, 23): raise ValueError( f"unknown checkpoint weight {key!r}: the vision_top stage " "trains only the last four blocks (20-23)" @@ -329,12 +327,52 @@ def run_export( through ``safe_open``. ``sources`` is recorded verbatim in the manifest; ``merge_config``/``head_config``/``expected`` are validated against the tensors before anything is written. Returns the manifest dict. + + A failed run leaves no partial output behind: every artifact this attempt + wrote is removed (and a directory it created is removed too), so the + not-empty guard never blocks a retry with the same arguments. """ out_dir = Path(out_dir) if out_dir.exists() and any(out_dir.iterdir()): raise ValueError(f"output directory {out_dir} already exists and is not empty") + created = not out_dir.exists() out_dir.mkdir(parents=True, exist_ok=True) + try: + return _write_export( + checkpoint_weights, + base_file, + out_dir, + sources=sources, + merge_config=merge_config, + head_config=head_config, + expected=expected, + base_config_path=base_config_path, + ) + except BaseException: + for name in (BACKBONE_FILE, HEAD_FILE, MANIFEST_FILE, "config.json"): + (out_dir / name).unlink(missing_ok=True) + if created: + try: + out_dir.rmdir() # empty: everything this run wrote is gone + except OSError: # never mask the failure being reported + pass + raise + + +def _write_export( + checkpoint_weights, + base_file, + out_dir, + *, + sources, + merge_config, + head_config, + expected, + base_config_path=None, +): + """The export body guarded by :func:`run_export`'s cleanup wrapper.""" + # Single-threaded CPU math keeps the FP32 merge GEMM bit-reproducible # regardless of the ambient thread/env configuration. torch.set_grad_enabled(False) @@ -610,14 +648,15 @@ def validate_export(out_dir): from safetensors import safe_open out_dir = Path(out_dir) - present = sorted(p.name for p in out_dir.iterdir() if p.is_file()) + # Any unexpected entry — file, directory or otherwise — fails the audit. + present = sorted(p.name for p in out_dir.iterdir()) allowed = sorted([BACKBONE_FILE, HEAD_FILE, MANIFEST_FILE, "config.json"]) if present != allowed: extra = sorted(set(present) - set(allowed)) missing = sorted(set(allowed) - set(present)) raise ValueError( f"export directory must hold exactly {allowed}: " - f"unexpected files {extra}, missing {missing}" + f"unexpected entries {extra}, missing {missing}" ) manifest = json.loads((out_dir / MANIFEST_FILE).read_text(encoding="utf-8")) if manifest.get("format") != FORMAT: diff --git a/recipe/valen/verify_export_parity.py b/recipe/valen/verify_export_parity.py index 297d3633..eb52535d 100644 --- a/recipe/valen/verify_export_parity.py +++ b/recipe/valen/verify_export_parity.py @@ -441,7 +441,7 @@ def run_stage(args): if device.startswith("cuda"): index = torch.device(device).index or 0 capability = torch.cuda.get_device_capability(index) - free, total = torch.cuda.mem_get_info(index) + _, total = torch.cuda.mem_get_info(index) hardware = ( f"{torch.cuda.get_device_name(index)} " f"(sm_{capability[0]}{capability[1]}, {total / (1024 ** 3):.2f} GiB)" diff --git a/tests/valen/test_export.py b/tests/valen/test_export.py index 221eb702..045404ef 100644 --- a/tests/valen/test_export.py +++ b/tests/valen/test_export.py @@ -221,6 +221,20 @@ def test_classify_checkpoint_weights_rejects_non_visual_block_tower(): ) +def test_classify_checkpoint_weights_rejects_block_beyond_the_top_four(): + """Blocks at or above 24 are outside the trained 20-23 set and must be + rejected exactly like sub-20 blocks (the pinned inventory counts alone + would not catch a swapped-in block 24 replacing a missing one).""" + + import pytest + + keys = synthetic_checkpoint_keys() + keys.remove("backbone.visual.blocks.20.norm1.weight") + keys.append("backbone.visual.blocks.24.norm1.weight") + with pytest.raises(ValueError, match="vision_top"): + tool.classify_checkpoint_weights(keys) + + def test_validate_inventory_counts(): classification = tool.classify_checkpoint_weights(synthetic_checkpoint_keys()) expected = { @@ -477,6 +491,69 @@ def test_run_export_rejects_existing_output(tmp_path): run_tiny_export(out, tmp_path / "in-b") +def test_run_export_failure_leaves_no_partial_output(tmp_path, monkeypatch): + """A failed export must not leave a half-written output directory: the + not-empty guard would then block every retry with the same arguments.""" + + import pytest + + real_write = tool.write_safetensors + + def exploding_write(path, entries): + real_write(path, entries) + raise ValueError("boom after writing") + + monkeypatch.setattr(tool, "write_safetensors", exploding_write) + base_file, config_file = materialize_input_dir(tmp_path / "in") + out = tmp_path / "export" + with pytest.raises(ValueError, match="boom"): + tool.run_export( + tiny_checkpoint_weights(), + base_file, + out, + sources=TINY_SOURCES, + merge_config=TINY_MERGE, + head_config=TINY_HEAD, + expected=TINY_INVENTORY, + base_config_path=config_file, + ) + assert not out.exists() or not any(out.iterdir()) + + # The same arguments now succeed into the same directory. + monkeypatch.setattr(tool, "write_safetensors", real_write) + run_tiny_export(out, tmp_path / "in-a") + + +def test_run_export_failure_cleans_preexisting_empty_output(tmp_path, monkeypatch): + """Same contract when --out pointed at a pre-existing empty directory: + the retry must find it empty again (the directory itself may survive).""" + + import pytest + + real_write = tool.write_safetensors + + def exploding_write(path, entries): + real_write(path, entries) + raise ValueError("boom after writing") + + monkeypatch.setattr(tool, "write_safetensors", exploding_write) + base_file, config_file = materialize_input_dir(tmp_path / "in") + out = tmp_path / "export" + out.mkdir() + with pytest.raises(ValueError, match="boom"): + tool.run_export( + tiny_checkpoint_weights(), + base_file, + out, + sources=TINY_SOURCES, + merge_config=TINY_MERGE, + head_config=TINY_HEAD, + expected=TINY_INVENTORY, + base_config_path=config_file, + ) + assert not any(out.iterdir()) + + def test_run_export_rejects_unexpected_fp32_base_tensor(tmp_path): import pytest import torch @@ -637,7 +714,7 @@ def test_validate_export_detects_extra_files_and_forgotten_entries(tmp_path): out = tmp_path / "export" run_tiny_export(out, tmp_path / "in-a") (out / "surprise.bin").write_bytes(b"x") - with pytest.raises(ValueError, match="unexpected file"): + with pytest.raises(ValueError, match="unexpected"): tool.validate_export(out) (out / "surprise.bin").unlink() @@ -649,6 +726,19 @@ def drop_file_record(manifest): tool.validate_export(out) +def test_validate_export_rejects_extra_directories(tmp_path): + """The "exactly four files" audit must reject any unexpected entry, not + only unexpected regular files — otherwise a stray subdirectory passes.""" + + import pytest + + out = tmp_path / "export" + run_tiny_export(out, tmp_path / "in-a") + (out / "scratch").mkdir() + with pytest.raises(ValueError, match="unexpected"): + tool.validate_export(out) + + # --- real-artifact pin helpers (CPU-only, no multi-GB reads) --- def test_base_weights_file_requires_single_shard(tmp_path): From 3a8c3fd4e08186bc35c33a757dd3b905d716494e Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 00:28:26 +0800 Subject: [PATCH 17/32] valen: record the failed parity verdict in export.md and refresh counts The fallback paragraph claimed the gate passes and the fallback is deliberately unimplemented, contradicting reference.md's recorded FAILED verdict (max probability delta 1.7e-2). It now states the failure and leaves the fallback decision to the spec owner (local ticket E4). The suite count is updated to the measured 105 passed / 2 artifact-gated skips. --- recipe/valen/export.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/recipe/valen/export.md b/recipe/valen/export.md index 184b62c8..3b5f5721 100644 --- a/recipe/valen/export.md +++ b/recipe/valen/export.md @@ -103,9 +103,13 @@ boundaries. The pinned config's scaling is α/r = 64/32 = 2.0 over 372 LoRA keys targeting 186 language linears. The fallback, recorded in the manifest's merge block, is to export the -FP32 LoRA separately and serve unmerged — a mode that triggers only if -the parity gate fails (spec 02, user story 9). It is deliberately not -implemented while the gate passes. +FP32 LoRA separately and serve unmerged (spec 02, user story 9). The +parity gate has since failed on the pinned artifacts — the worst +per-case probability delta is 1.7e-2 against the 1e-6 tolerance; the +verdict and its numbers are recorded in +[reference.md](reference.md) — so whether that fallback should be +implemented is an open decision for the spec owner (tracked locally as +decision ticket E4). This document does not presuppose its outcome. ## The FP32 downcasts @@ -182,8 +186,8 @@ PYTHONPATH=src .venv/bin/python -m pytest tests/valen -q python recipe/valen/test_export_reference.py ~~~ -The first is the CPU-only suite (71 tests passed, 1 artifact-gated skip at -the time of writing). The second chains that suite's export battery plus +The first is the CPU-only suite (105 tests passed, 2 artifact-gated skips +at the time of writing). The second chains that suite's export battery plus the env-gated real-artifact determinism gate — set `VALEN_CKPT` and `VALEN_BASE` to run it; it exports twice and compares every byte — and, with `VALEN_EXPORT` pointing at an exported directory (defaulting to From e24e356df1092d91e5ab7f59a71ba5ad830acf45 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 00:30:41 +0800 Subject: [PATCH 18/32] valen: inject monkeypatch into the export reference battery The recipe regression script introspects tests/valen/test_export.py and injects fixtures by parameter name, but only knew tmp_path; the new streaming/cleanup tests take monkeypatch and failed under the script while passing under pytest. The battery now hands each test a fresh pytest MonkeyPatch (undone after the test) for that parameter. --- recipe/valen/test_export_reference.py | 33 +++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/recipe/valen/test_export_reference.py b/recipe/valen/test_export_reference.py index 680bf0b9..91b4d565 100644 --- a/recipe/valen/test_export_reference.py +++ b/recipe/valen/test_export_reference.py @@ -35,17 +35,24 @@ import test_export as contract # noqa: E402 +FIXTURE_PARAMS = ("tmp_path", "monkeypatch") + + def contract_tests(): """The export contract battery from tests/valen/test_export.py, in module - order; ``tmp_path``-taking tests get a fresh directory each.""" + order; each recognized fixture parameter gets a fresh instance per + test (``tmp_path`` a fresh directory, ``monkeypatch`` a pytest + ``MonkeyPatch`` undone right after).""" tests = [] for name in sorted(dir(contract)): if not name.startswith("test_"): continue function = getattr(contract, name) - takes_tmp_path = "tmp_path" in function.__code__.co_varnames - tests.append((name, function, takes_tmp_path)) + params = tuple( + param for param in FIXTURE_PARAMS if param in function.__code__.co_varnames + ) + tests.append((name, function, params)) return tests @@ -57,13 +64,21 @@ def run_contract_battery(results): test's own reason, never as a failure. """ - for name, function, takes_tmp_path in contract_tests(): + import pytest + + for name, function, params in contract_tests(): try: - if takes_tmp_path: - with tempfile.TemporaryDirectory() as tmp: - function(Path(tmp)) - else: - function() + with tempfile.TemporaryDirectory() as tmp: + monkeypatch = pytest.MonkeyPatch() + kwargs = {} + if "tmp_path" in params: + kwargs["tmp_path"] = Path(tmp) + if "monkeypatch" in params: + kwargs["monkeypatch"] = monkeypatch + try: + function(**kwargs) + finally: + monkeypatch.undo() except BaseException as error: # noqa: B036 - classified below if type(error).__name__ == "Skipped": record(results, name, "skip", str(error)) From a207648ba70a4bb2ec7cf70684008b42be5b2f91 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 17:26:00 +0800 Subject: [PATCH 19/32] docs(valen): strip planning narrative and simplify export docs --- recipe/valen/export.md | 16 +++++------ recipe/valen/export_checkpoint.py | 41 +++++++++++++-------------- recipe/valen/reference.md | 8 +++--- recipe/valen/test_export_reference.py | 4 +-- recipe/valen/verify_export_parity.py | 37 ++++++++++++------------ src/models/valen/README.md | 8 +++--- tests/valen/test_export.py | 10 +++---- tests/valen/test_export_parity.py | 28 +++++++++--------- 8 files changed, 73 insertions(+), 79 deletions(-) diff --git a/recipe/valen/export.md b/recipe/valen/export.md index 3b5f5721..522f320b 100644 --- a/recipe/valen/export.md +++ b/recipe/valen/export.md @@ -5,8 +5,8 @@ base into a safetensors artifact set that any consumer — native or otherwise — can load without the training-framework pickle or the reference Python code. One deterministic command performs every transformation and records each one in a JSON manifest, so the artifact set can be audited on -its own. It implements spec 02 (checkpoint export with parity evidence); -serving the export is spec 03 and out of scope here. +its own. Building, validating and parity-checking the artifact set is this +recipe's scope; serving from it is the consumer's side. The export complements the [reference worker recipe](reference.md): the worker serves through the @@ -103,13 +103,11 @@ boundaries. The pinned config's scaling is α/r = 64/32 = 2.0 over 372 LoRA keys targeting 186 language linears. The fallback, recorded in the manifest's merge block, is to export the -FP32 LoRA separately and serve unmerged (spec 02, user story 9). The -parity gate has since failed on the pinned artifacts — the worst -per-case probability delta is 1.7e-2 against the 1e-6 tolerance; the -verdict and its numbers are recorded in -[reference.md](reference.md) — so whether that fallback should be -implemented is an open decision for the spec owner (tracked locally as -decision ticket E4). This document does not presuppose its outcome. +FP32 LoRA separately and serve unmerged — the only path that meets the +reference-serving tolerance. It is not implemented; the merged artifact +set keeps exact discrete answers and carries `parity.status = "failed"` +in `valen_export.json` (per-case deltas, device, tolerances and the drift +aggregate are recorded there and in [reference.md](reference.md)). ## The FP32 downcasts diff --git a/recipe/valen/export_checkpoint.py b/recipe/valen/export_checkpoint.py index 00099698..58aeef7d 100644 --- a/recipe/valen/export_checkpoint.py +++ b/recipe/valen/export_checkpoint.py @@ -4,7 +4,7 @@ One deterministic command turns the training-framework pickle (``checkpoint.pt`` + the pinned Qwen3.5-2B base) into the artifact set a native worker can load, plus a JSON manifest recording every transformation -performed (spec 02): +performed: - ``model.safetensors`` — the backbone in the base's own key layout (``model.language_model.*`` / ``model.visual.*``, BF16 only): the language @@ -22,7 +22,7 @@ component attribution (backbone / lora-merged / vision-top / merger / decision-head), source revisions and SHA-256s, the merge decision and scaling, the downcast list, the head configuration, and a ``parity`` slot - left ``null`` for the E2 parity gate to fill. + filled by ``verify_export_parity.py``. Determinism: no timestamps, no absolute paths, tensors and JSON written in sorted key order, single-threaded CPU math — two runs on the same inputs @@ -55,9 +55,8 @@ HEAD_FILE = "head.safetensors" MANIFEST_FILE = "valen_export.json" -# Checkpoint key layout, verified in wayfinder/research/001: PEFT wraps -# backbone.language_model, vision-top/merger tensors ride under -# backbone.visual.*, and the decision head is head.*. +# Checkpoint key layout: PEFT wraps backbone.language_model, vision-top and +# merger tensors ride under backbone.visual.*, and the decision head is head.*. PEFT_LANGUAGE_PREFIX = "backbone.language_model.base_model.model." PEFT_LORA_SUFFIX_A = ".lora_A.default.weight" PEFT_LORA_SUFFIX_B = ".lora_B.default.weight" @@ -76,10 +75,9 @@ COMPONENT_HEAD, ) -# The pinned Preview inventory (research 001, Part 1a): 428 trainable -# tensors decomposing as 372 LoRA keys over the 186 targeted language -# linears, 48 vision-top block tensors, 6 merger tensors and 2 head -# projections; the base file holds 632 tensors of which 36 are FP32. +# The pinned Preview inventory: 428 trainable tensors (372 LoRA keys over +# 186 targeted linears, 48 vision-top, 6 merger, 2 head); the base holds 632 +# tensors, 36 of them FP32. PINNED_INVENTORY = { "checkpoint_tensors": 428, "lora_keys": 372, @@ -90,13 +88,13 @@ "base_fp32_tensors": 36, } -# Documented fallback if the E2 parity gate fails (spec 02, user story 9). +# The fallback the manifest records for a failed parity gate. MERGE_FALLBACK = ( "export the FP32 LoRA separately and serve unmerged " - "(spec 02 user story 9; triggered only by a failed parity gate)" + "(applies when the parity gate fails)" ) -# The shape of the parity slot E2 fills into the manifest (left null here). +# The parity record's shape; the manifest ships it null until measured. PARITY_SLOT_SHAPE = { "status": "passed | failed", "device": "torch device string", @@ -152,10 +150,10 @@ class Classification(NamedTuple): sorted key order so downstream output is deterministic. """ - lora: dict # base tensor name -> (a_key, b_key) - vision_top: list # sorted (export_key, checkpoint_key) - merger: list # sorted (export_key, checkpoint_key) - head: dict # export key -> checkpoint key + lora: dict + vision_top: list + merger: list + head: dict _VISION_BLOCK = re.compile(r"^backbone\.visual\.blocks\.(\d+)\.(.+)$") @@ -167,8 +165,8 @@ def classify_checkpoint_weights(keys): Rejects anything outside the pinned ``vision_top`` layout: frozen backbone tensors, non-PEFT language keys, head biases and unpaired or - non-top-4 vision adapters are all errors, because the export recipe is - only valid for the component inventory research 001 verified. + non-top-4 vision adapters are all errors — the recipe is only valid + for this checkpoint's verified component layout. """ lora_a = {} @@ -354,8 +352,8 @@ def run_export( (out_dir / name).unlink(missing_ok=True) if created: try: - out_dir.rmdir() # empty: everything this run wrote is gone - except OSError: # never mask the failure being reported + out_dir.rmdir() + except OSError: pass raise @@ -397,8 +395,7 @@ def _write_export( base_dtypes[name] = _READER_DTYPES[slice_.get_dtype()] base_shapes[name] = list(slice_.get_shape()) - # Every FP32 base tensor must be a declared downcast target; anything - # else is a silent dtype surprise (spec 02, user story 7). + # Every FP32 base tensor must be a declared downcast target. downcasts = [name for name in base_names if base_dtypes[name] == torch.float32] for name in downcasts: if not _FP32_TENSOR.match(name): diff --git a/recipe/valen/reference.md b/recipe/valen/reference.md index 60355f46..a09a44a9 100644 --- a/recipe/valen/reference.md +++ b/recipe/valen/reference.md @@ -458,7 +458,7 @@ and [`example-score-request.json`](example-score-request.json) unless one model load. Both blocks above are verbatim output of the committed tool; the `noul` and `score` blocks are recorded in the integration pull request. -## Export parity (spec 02) +## Export parity The exported artifact set ([`export.md`](export.md)) was compared against the original pinned checkpoint stack with @@ -519,8 +519,8 @@ identical, but the merged export's probabilities drift by 7.1e-4 (text) and 1.7e-2 (image) — the merge drift accumulating through the 24 adapted layers, three orders of magnitude above the tolerance. The drift measurement itself is bounded at one BF16 ulp per adapted module (max ulp ratio exactly 1, ~53% -of probe activations bit-exact), matching the research-001 per-layer -estimate; the failure is the accumulation, not any single module. +of probe activations bit-exact); the failure is the accumulation, not any +single module. Control checks recorded with the run: @@ -534,7 +534,7 @@ Control checks recorded with the run: - every exported tensor was cross-checked bit-exactly against the export tool's merge math on the given base and checkpoint before the drift run. -Per spec 02 user story 9, the failed gate triggers the documented fallback: +The failed gate triggers the documented fallback: export the FP32 LoRA separately and serve unmerged (recorded in the manifest's `merge.fallback`), which is the only path that meets the reference-serving tolerance. The merged artifact set keeps exact discrete diff --git a/recipe/valen/test_export_reference.py b/recipe/valen/test_export_reference.py index 91b4d565..cc52e0db 100644 --- a/recipe/valen/test_export_reference.py +++ b/recipe/valen/test_export_reference.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Regression wiring for the Valen checkpoint export (spec 02, E3). +"""Regression wiring for the Valen checkpoint export. Chains the export contract tests in ``tests/valen/test_export.py`` — the CPU-only battery plus the env-gated real-artifact determinism gate — and @@ -105,7 +105,7 @@ def validate_artifact_set(results, export_dir): + ", ".join(f"{counts[c]} {c}" for c in sorted(counts)) + f", {len(manifest['downcasts'])} fp32->bf16 downcasts" + f", merge {manifest['merge']['decision']}" - + f", parity slot {'filled' if parity is not None else 'not yet recorded (E2)'}" + + f", parity slot {'filled' if parity is not None else 'not recorded'}" ) record(results, f"validate {export_dir}", True, summary) diff --git a/recipe/valen/verify_export_parity.py b/recipe/valen/verify_export_parity.py index eb52535d..78ab51af 100644 --- a/recipe/valen/verify_export_parity.py +++ b/recipe/valen/verify_export_parity.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Prove the exported Valen artifact set answers like the original checkpoint. -Spec 02's parity gate (ticket E2). Two model stacks are loaded serially — +Two model stacks are loaded serially — only one lives on the GPU at a time, each in its own subprocess so the ~6.6 GiB of VRAM is fully released between loads: @@ -27,8 +27,8 @@ the 186 merged keys it compares the unmerged PEFT application (base W plus the FP32 ``2*(B@A)`` delta on the activations, one final BF16 rounding — the reference's semantics) against the exported merged BF16 weight's GEMM, -using the research-001 probe protocol (256 bf16 standard-normal rows, seed -0, CPU single thread), and aggregates max/mean. +with a fixed probe (256 BF16 standard-normal rows, seed 0, single-threaded +CPU), and aggregates max/mean. Finally it fills the manifest's ``parity`` slot in ``weights/valen-export/valen_export.json`` with the verdict (status, device, @@ -42,8 +42,8 @@ --valen-source weights/Valen --checkpoint weights/Valen-Preview-0923 \ --base weights/Qwen3.5-2B --export weights/valen-export --device cuda -Exit status 0 means the gate passed; 1 means it failed (the spec-02 -fallback — export the FP32 LoRA separately, serve unmerged — then applies). +Exit status 0 means the gate passed; 1 means it failed (the documented +fallback — export the FP32 LoRA separately and serve unmerged — applies). """ from __future__ import annotations @@ -60,7 +60,7 @@ sys.path.insert(0, str(HERE)) import compare_reference as reference # noqa: E402 (the recipe's fixture helpers) -import export_checkpoint as exporter # noqa: E402 (E1's export + validation tool) +import export_checkpoint as exporter # noqa: E402 PROBABILITY_TOLERANCE = 1e-6 DRIFT_ROWS = 256 @@ -70,12 +70,12 @@ def merge_lora_weight(base_weight, lora_a, lora_b, scaling): - """E1's merge math (bit-equal to peft 0.18.1's ``Linear.merge``).""" + """The export tool's merge math (bit-equal to peft 0.18.1's ``Linear.merge``).""" return exporter.merge_lora_weight(base_weight, lora_a, lora_b, scaling) -# --- the drift ruler: bf16 spacing at a magnitude (research 001's ulp column) --- +# --- the drift ruler: bf16 spacing at a magnitude --- def bf16_ulp_at(magnitude): @@ -94,7 +94,7 @@ def bf16_ulp_at(magnitude): def module_output_drift(base_weight, lora_a, lora_b, merged_weight, scaling, probe): """Output drift of one adapted module: unmerged (PEFT, the reference) - vs merged (the exported BF16 weight), following research 001. + vs merged (the exported BF16 weight). Unmerged arm — peft 0.18.1 ``Linear.forward`` semantics: a BF16 base GEMM, the ``scaling * (B(A(x)))`` delta computed and added in FP32, one @@ -107,9 +107,9 @@ def module_output_drift(base_weight, lora_a, lora_b, merged_weight, scaling, pro if base_weight.dtype != torch.bfloat16 or merged_weight.dtype != torch.bfloat16: raise ValueError("base and merged weights must be bf16") - y_base = torch.matmul(probe, base_weight.t()) # base_layer(x), BF16 GEMM - delta = scaling * ((probe.float() @ lora_a.t()) @ lora_b.t()) # FP32 LoRA path - y_unmerged = (y_base.float() + delta).to(torch.bfloat16) # one final rounding + y_base = torch.matmul(probe, base_weight.t()) + delta = scaling * ((probe.float() @ lora_a.t()) @ lora_b.t()) + y_unmerged = (y_base.float() + delta).to(torch.bfloat16) y_merged = torch.matmul(probe, merged_weight.t()) diff = (y_merged.float() - y_unmerged.float()).abs() @@ -321,7 +321,8 @@ def load_exported_stack(export_dir, projection_dim, device): ): raise ValueError(f"Incomplete exported model load: {loading_info}") backbone = container.model - del container # the generation/vocabulary layer is never executed + # Only container.model is served; drop the vocabulary/generation wrapper. + del container backbone.requires_grad_(False) model = Valen(backbone, projection_dim) with safe_open(Path(export_dir) / exporter.HEAD_FILE, framework="pt") as handle: @@ -506,10 +507,10 @@ def measure_merge_drift(checkpoint_dir, base_dir, export_dir, *, rows=DRIFT_ROWS The unmerged arm uses the base file's BF16 weight plus the checkpoint's FP32 LoRA (reference semantics); the merged arm uses the tensor actually sitting in the exported ``model.safetensors``, cross-checked bit-exactly - against E1's merge math so the measurement can never run against a - silently different weight. Probe protocol: ``rows`` BF16 standard-normal - activations per module, one generator seeded once, modules in sorted - name order, CPU single thread (the research-001 method). + against the export tool's merge math so the measurement can never run + against a silently different weight. Probe protocol: ``rows`` BF16 + standard-normal activations per module, one generator seeded once, + modules in sorted name order, CPU single thread. """ import torch @@ -694,7 +695,7 @@ def main(argv=None): "fp32 2*(B@A) delta on activations with one final bf16 rounding " "(peft 0.18.1 Linear.forward); merged = bf16 GEMM(bf16(W + 2*(B@A))) " "from the export; probe {rows}x bf16 standard-normal rows, seed {seed}, " - "cpu single thread (wayfinder/research/001 method)" + "cpu single thread" ).format(rows=args.rows, seed=args.seed), "modules": aggregate["modules"], "aggregate": aggregate, diff --git a/src/models/valen/README.md b/src/models/valen/README.md index 4416ae4f..016148fb 100644 --- a/src/models/valen/README.md +++ b/src/models/valen/README.md @@ -1,10 +1,10 @@ # Valen-Preview-0923 reference contract -This directory contains the first System1-Omni integration slice for +This directory contains the System1-Omni reference integration for Valen-Preview-0923. It is a reference-serving boundary only: the Python worker, processor, executor, and postprocessor serve the pinned text and single-image `choice`, `noul`, and `score` questions. Native CUDA execution is not part of -this slice. +it. ## Pinned artifacts @@ -89,7 +89,7 @@ The pinned compiler builds every question type from the same record family: with unspecified sides filled from the same fixed wording — `P(true)` still comes from the two-candidate softmax. -Postprocessing keeps Valen's original formulas for this reference slice: +Postprocessing keeps Valen's original formulas: ~~~text choice/noul confidence (n candidates): @@ -175,7 +175,7 @@ The pinned checkpoint and base also export to a native-loadable safetensors artifact set — LoRA merged, vision-top and merger replaced, FP32 decision head — with every transformation recorded in a JSON manifest: see [recipe/valen/export.md](../../../recipe/valen/export.md). -Consuming that export is a later slice and does not change this worker. +Consuming that export does not change this worker. ## Ownership boundary diff --git a/tests/valen/test_export.py b/tests/valen/test_export.py index 045404ef..e74e65b8 100644 --- a/tests/valen/test_export.py +++ b/tests/valen/test_export.py @@ -1,11 +1,11 @@ -"""Export-tool tests for recipe/valen/export_checkpoint.py (spec 02, E1). +"""Export-tool tests for recipe/valen/export_checkpoint.py. CPU-only checks of the export's observable contract: the LoRA merge and FP32->BF16 downcast semantics on synthetic tensors (against the installed PEFT as the independent reference), checkpoint-key classification, and the manifest/artifact consistency on a tiny synthetic fixture. The full determinism run against the real pinned artifacts is env-gated on -VALEN_CKPT / VALEN_BASE, following the CUA_S1_BASE precedent. +VALEN_CKPT / VALEN_BASE. PYTHONPATH=src python -m pytest tests/valen -q """ @@ -192,9 +192,9 @@ def test_classify_checkpoint_weights_rejects_unknown_keys(): import pytest for key in ( - "backbone.visual.patch_embed.proj.weight", # frozen, never in checkpoint - "backbone.language_model.layers.0.mlp.down_proj.weight", # base weight, not PEFT - "head.decision.bias", # the head is bias-free + "backbone.visual.patch_embed.proj.weight", + "backbone.language_model.layers.0.mlp.down_proj.weight", + "head.decision.bias", "optimizer.steps", ): with pytest.raises(ValueError, match="unknown checkpoint weight"): diff --git a/tests/valen/test_export_parity.py b/tests/valen/test_export_parity.py index 1be4615b..9cb03dfa 100644 --- a/tests/valen/test_export_parity.py +++ b/tests/valen/test_export_parity.py @@ -1,12 +1,11 @@ -"""Parity-gate tests for recipe/valen/verify_export_parity.py (spec 02, E2). +"""Parity-gate tests for recipe/valen/verify_export_parity.py. CPU-only checks of the export-parity tool's observable contract: the -merge-drift measurement (PEFT unmerged vs merged BF16 semantics, the -research-001 method) on hand-computed tensors, the drift aggregation, the -original-vs-exported case comparison verdicts, and the manifest parity-slot -filling. The full parity gate against the real pinned artifacts is env-gated -on VALEN_CKPT / VALEN_BASE / VALEN_SRC plus CUDA, following the test_export.py -precedent. +merge-drift measurement (PEFT unmerged vs merged BF16 semantics) on +hand-computed tensors, the drift aggregation, the original-vs-exported +case comparison verdicts, and the manifest parity-slot filling. The full +parity gate against the real pinned artifacts is env-gated on +VALEN_CKPT / VALEN_BASE / VALEN_SRC plus CUDA. PYTHONPATH=src python -m pytest tests/valen -q """ @@ -27,7 +26,7 @@ import verify_export_parity as tool # noqa: E402 -# --- the BF16 ulp ruler (research 001 reports spacing at max |y|) --- +# --- the BF16 ulp ruler --- def test_bf16_ulp_at_known_magnitudes(): # bf16 keeps 8 significant bits, so values in [2^e, 2^(e+1)) sit on a @@ -76,7 +75,7 @@ def test_module_output_drift_hand_computed_one_ulp(): lora_a = torch.tensor([[0.0019]], dtype=torch.float32) lora_b = torch.tensor([[1.0]], dtype=torch.float32) merged = tool.merge_lora_weight(base, lora_a, lora_b, 2.0) - assert merged.item() == 1.0 # the weight rounds back to the base + assert merged.item() == 1.0 probe = torch.tensor([[3.0]], dtype=torch.bfloat16) metrics = tool.module_output_drift(base, lora_a, lora_b, merged, 2.0, probe) @@ -129,10 +128,9 @@ def forward(self, x): peft_unmerged = model(probe).detach() metrics = tool.module_output_drift(base_weight, lora_a, lora_b, merged, scaling, probe) - assert metrics["max_abs_diff"] > 0.0 # the merge really does drift + assert metrics["max_abs_diff"] > 0.0 assert metrics["bitexact_fraction"] < 1.0 - # Recomputing the unmerged arm by hand must agree with peft bit-exactly: - # the drift tool's semantics are the reference's, not a re-invention. + # The hand-computed unmerged arm must match peft bit-exactly. y_base = torch.matmul(probe, base_weight.t()) delta = scaling * ((probe.float() @ lora_a.t()) @ lora_b.t()) y_unmerged = (y_base.float() + delta).to(torch.bfloat16) @@ -212,7 +210,7 @@ def test_compare_case_delta_beyond_tolerance_fails(): original = case_side([0.5159631, 0.4840369]) drifted = case_side([0.5159631 + 2e-6, 0.4840369 - 2e-6]) verdict = tool.compare_case("text", original, drifted) - assert verdict["exact_answer_agreement"] is True # argmax still agrees + assert verdict["exact_answer_agreement"] is True assert verdict["probability_tolerance_pass"] is False assert verdict["passed"] is False assert any("tolerance" in problem for problem in verdict["problems"]) @@ -396,7 +394,7 @@ def pin_fixture(tmp_path): def test_verify_sources_match_manifest_accepts_matching_pins(tmp_path): args, manifest = pin_fixture(tmp_path) - tool.verify_sources_match_manifest(args, manifest) # no exception + tool.verify_sources_match_manifest(args, manifest) def test_verify_sources_rejects_checkpoint_digest_drift(tmp_path): @@ -437,7 +435,7 @@ def test_verify_sources_rejects_swapped_base_shard_bytes(tmp_path): args, manifest = pin_fixture(tmp_path) shard = tmp_path / "base" / "model.safetensors-00001-of-00001.safetensors" - shard.write_bytes(b"different-shard-bytes") # digest record stays honest + shard.write_bytes(b"different-shard-bytes") with pytest.raises(ValueError, match="base weights sha256"): tool.verify_sources_match_manifest(args, manifest) From fe9634842eba2c0da7c9412961eee6b2635a1b83 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 18:48:52 +0800 Subject: [PATCH 20/32] valen: export the language LoRA separately and drop the merged path --- recipe/valen/export_checkpoint.py | 271 +++++++++++++++++++----------- tests/valen/test_export.py | 257 +++++++++++++++++++--------- 2 files changed, 353 insertions(+), 175 deletions(-) diff --git a/recipe/valen/export_checkpoint.py b/recipe/valen/export_checkpoint.py index 58aeef7d..ddc90cb9 100644 --- a/recipe/valen/export_checkpoint.py +++ b/recipe/valen/export_checkpoint.py @@ -7,22 +7,26 @@ performed: - ``model.safetensors`` — the backbone in the base's own key layout - (``model.language_model.*`` / ``model.visual.*``, BF16 only): the language - LoRA merged at BF16 (``bf16(fp32(W) + 2*(B@A))``, bit-equal to peft - 0.18.1's ``Linear.merge``), the trained vision-top blocks 20-23 and the - merger substituted as plain BF16 replacements, and the base's 36 FP32 - tensors (``linear_attn.A_log`` / ``linear_attn.norm.weight``) downcast to - BF16 — the same cast transformers' own BF16 runtime performs. + (``model.language_model.*`` / ``model.visual.*``, BF16 only): the base + weights verbatim except the trained vision-top blocks 20-23 and the + merger substituted as plain BF16 replacements and the base's 36 FP32 + tensors (``linear_attn.A_log`` / ``linear_attn.norm.weight``) downcast + to BF16 — the same cast transformers' own BF16 runtime performs. The + 186 language LoRA targets are base tensors here, untouched. +- ``lora.safetensors`` — the language LoRA served unmerged: the 372 FP32 + adapter tensors under their PEFT key names, verbatim from the + checkpoint. - ``head.safetensors`` — the decision head's two FP32 projection matrices (``head.decision.weight``, ``head.candidate.weight``), no bias. - ``config.json`` — the base config copied verbatim so the reference builder (``Qwen3_5ForConditionalGeneration.from_pretrained(out, dtype=bf16, attn_implementation="eager").model``) loads the export unchanged. - ``valen_export.json`` — the manifest: per-tensor name/shape/dtype and - component attribution (backbone / lora-merged / vision-top / merger / - decision-head), source revisions and SHA-256s, the merge decision and - scaling, the downcast list, the head configuration, and a ``parity`` slot - filled by ``verify_export_parity.py``. + component attribution (backbone / lora / vision-top / merger / + decision-head), source revisions and SHA-256s, the LoRA configuration + and its declared application semantics, the downcast list, the head + configuration, and a ``parity`` slot filled by + ``verify_export_parity.py``. Determinism: no timestamps, no absolute paths, tensors and JSON written in sorted key order, single-threaded CPU math — two runs on the same inputs @@ -50,8 +54,9 @@ import torch -FORMAT = "valen-checkpoint-export/1" +FORMAT = "valen-checkpoint-export/2" BACKBONE_FILE = "model.safetensors" +LORA_FILE = "lora.safetensors" HEAD_FILE = "head.safetensors" MANIFEST_FILE = "valen_export.json" @@ -63,13 +68,13 @@ HEAD_PREFIX = "head." COMPONENT_BACKBONE = "backbone" -COMPONENT_LORA_MERGED = "lora-merged" +COMPONENT_LORA = "lora" COMPONENT_VISION_TOP = "vision-top" COMPONENT_MERGER = "merger" COMPONENT_HEAD = "decision-head" COMPONENTS = ( COMPONENT_BACKBONE, - COMPONENT_LORA_MERGED, + COMPONENT_LORA, COMPONENT_VISION_TOP, COMPONENT_MERGER, COMPONENT_HEAD, @@ -88,23 +93,26 @@ "base_fp32_tensors": 36, } -# The fallback the manifest records for a failed parity gate. -MERGE_FALLBACK = ( - "export the FP32 LoRA separately and serve unmerged " - "(applies when the parity gate fails)" +# How the exported LoRA is applied at serving time; the manifest records +# this verbatim in its lora block and unmerged_lora_output() is its +# executable form. +LORA_APPLICATION = ( + "per targeted linear, y = bf16(fp32(bf16(x @ W.t())) + scaling * " + "((x.float() @ A.t()) @ B.t())): the base GEMM runs in BF16, the LoRA " + "delta is computed in FP32 on the activations and added to the upcast " + "base output, and the sum is rounded to BF16 exactly once — the unmerged " + "peft 0.18.1 Linear.forward with the adapters kept FP32 at runtime" ) # The parity record's shape; the manifest ships it null until measured. PARITY_SLOT_SHAPE = { "status": "passed | failed", "device": "torch device string", + "hardware": "GPU name and memory when the device is cuda", + "tolerance": "the probability-delta gate", "cases": "text and image choice fixtures compared", "exact_answer_agreement": True, - "max_probability_delta": "<= 1e-6", - "merge_drift": { - "per_layer_max_output_delta": "max |merged - unmerged| per adapted layer", - "aggregate": "worst-case relative drift across layers", - }, + "max_probability_delta": "<= tolerance (1e-6)", "evidence": "pointer to the recorded parity run", } @@ -115,21 +123,20 @@ def lora_scaling(rank, alpha): return alpha / rank -def merge_lora_weight(base_weight, lora_a, lora_b, scaling): - """Merge one LoRA pair into a base weight at the reference's cast - boundaries. +def unmerged_lora_output(base_weight, lora_a, lora_b, activations, scaling): + """Apply one LoRA pair the way the manifest declares it is served. - The delta ``scaling * (B @ A)`` is computed in FP32, added to the - FP32-upcast base weight, and the sum is rounded to the base dtype - (BF16) exactly once — bit-equal to peft 0.18.1's ``Linear.merge`` - (``get_delta_weight`` computes the transpose-free delta in FP32 and the - in-place add rounds once per weight element). Zero-B adapters merge - back to the base tensor bit-exactly. + The base GEMM runs in the base dtype (BF16), the delta + ``scaling * ((x @ A.t()) @ B.t())`` is computed in FP32 on the + activations and added to the FP32-upcast base output, and the sum is + rounded to the base dtype exactly once — bit-equal to peft 0.18.1's + unmerged ``Linear.forward`` with FP32 adapters. A zero-B adapter + reproduces the plain base GEMM bit-exactly. """ - base_fp32 = base_weight.to(torch.float32) - delta = lora_b.to(torch.float32) @ lora_a.to(torch.float32) - return (base_fp32 + scaling * delta).to(base_weight.dtype) + y_base = torch.matmul(activations, base_weight.t()) + delta = scaling * ((activations.float() @ lora_a.t()) @ lora_b.t()) + return (y_base.float() + delta).to(base_weight.dtype) def downcast_bf16(tensor): @@ -160,6 +167,19 @@ class Classification(NamedTuple): _HEAD_TENSORS = ("head.decision.weight", "head.candidate.weight") +def lora_key_target(key): + """The ``model.safetensors`` tensor a PEFT LoRA key adapts.""" + + for suffix in (PEFT_LORA_SUFFIX_A, PEFT_LORA_SUFFIX_B): + if key.startswith(PEFT_LANGUAGE_PREFIX) and key.endswith(suffix): + return ( + "model.language_model." + + key[len(PEFT_LANGUAGE_PREFIX): -len(suffix)] + + ".weight" + ) + raise ValueError(f"not a language LoRA key: {key!r}") + + def classify_checkpoint_weights(keys): """Map the checkpoint's weight keys onto export names by component. @@ -313,7 +333,7 @@ def run_export( out_dir, *, sources, - merge_config, + lora_config, head_config, expected, base_config_path=None, @@ -323,7 +343,7 @@ def run_export( ``checkpoint_weights`` is the unpickled ``payload["weights"]`` dict (mmap views are fine); ``base_file`` is the base safetensors path, read lazily through ``safe_open``. ``sources`` is recorded verbatim in the manifest; - ``merge_config``/``head_config``/``expected`` are validated against the + ``lora_config``/``head_config``/``expected`` are validated against the tensors before anything is written. Returns the manifest dict. A failed run leaves no partial output behind: every artifact this attempt @@ -342,13 +362,19 @@ def run_export( base_file, out_dir, sources=sources, - merge_config=merge_config, + lora_config=lora_config, head_config=head_config, expected=expected, base_config_path=base_config_path, ) except BaseException: - for name in (BACKBONE_FILE, HEAD_FILE, MANIFEST_FILE, "config.json"): + for name in ( + BACKBONE_FILE, + LORA_FILE, + HEAD_FILE, + MANIFEST_FILE, + "config.json", + ): (out_dir / name).unlink(missing_ok=True) if created: try: @@ -364,21 +390,21 @@ def _write_export( out_dir, *, sources, - merge_config, + lora_config, head_config, expected, base_config_path=None, ): """The export body guarded by :func:`run_export`'s cleanup wrapper.""" - # Single-threaded CPU math keeps the FP32 merge GEMM bit-reproducible - # regardless of the ambient thread/env configuration. + # Single-threaded CPU math keeps the downcast elementwise casts + # bit-reproducible regardless of the ambient thread/env configuration. torch.set_grad_enabled(False) torch.set_num_threads(1) classification = classify_checkpoint_weights(checkpoint_weights) validate_inventory(classification, expected) - scaling = lora_scaling(merge_config["rank"], merge_config["alpha"]) + scaling = lora_scaling(lora_config["rank"], lora_config["alpha"]) from safetensors import safe_open @@ -438,18 +464,20 @@ def _write_export( f"LoRA pair for {base_name!r} must be fp32, " f"got {lora_a.dtype}/{lora_b.dtype}" ) - if lora_a.shape[0] != merge_config["rank"]: + if lora_a.shape[0] != lora_config["rank"]: raise ValueError( f"LoRA rank for {base_name!r} is {lora_a.shape[0]}, " - f"expected {merge_config['rank']}" + f"expected {lora_config['rank']}" ) - if list(lora_a.shape) != [merge_config["rank"], base_shape[1]] or list( + if list(lora_a.shape) != [lora_config["rank"], base_shape[1]] or list( lora_b.shape - ) != [base_shape[0], merge_config["rank"]]: + ) != [base_shape[0], lora_config["rank"]]: raise ValueError( f"LoRA pair shape mismatch for {base_name!r}: " f"A {list(lora_a.shape)}, B {list(lora_b.shape)}, base {base_shape}" ) + if base_dtypes[base_name] != torch.bfloat16: + raise ValueError(f"LoRA target {base_name!r} must be a bf16 base tensor") head_entries = [] for export_name, checkpoint_name in sorted(classification.head.items()): @@ -470,36 +498,12 @@ def _write_export( (export_name, torch.float32, list(tensor.shape), (lambda t=tensor: t)) ) - # Plan and stream the backbone file entry by entry. + # Plan and stream the backbone file entry by entry: base tensors + # verbatim except the trained vision replacements and the declared + # downcasts. The LoRA targets are ordinary base tensors here. tensors = [] def plan(name): - if name in classification.lora: - a_key, b_key = classification.lora[name] - if base_dtypes[name] != torch.bfloat16: - raise ValueError(f"LoRA target {name!r} must be a bf16 base tensor") - tensors.append( - { - "name": name, - "shape": base_shapes[name], - "dtype": "bfloat16", - "component": COMPONENT_LORA_MERGED, - "source": "base+checkpoint", - } - ) - return ( - name, - torch.bfloat16, - base_shapes[name], - ( - lambda n=name, a=a_key, b=b_key: merge_lora_weight( - base.get_tensor(n), - checkpoint_weights[a], - checkpoint_weights[b], - scaling, - ) - ), - ) checkpoint_name = vision_by_export.get(name) or merger_by_export.get(name) if checkpoint_name is not None: tensors.append( @@ -559,9 +563,38 @@ def plan(name): ) backbone_entries = [plan(name) for name in base_names] + + # The adapter tensors ride in their own file under the checkpoint's + # PEFT key names, FP32 and unmerged. + lora_keys = sorted( + key + for pair in classification.lora.values() + for key in pair + ) + lora_entries = [ + ( + key, + torch.float32, + list(checkpoint_weights[key].shape), + (lambda k=key: checkpoint_weights[k]), + ) + for key in lora_keys + ] + for key in lora_keys: + tensors.append( + { + "name": key, + "shape": list(checkpoint_weights[key].shape), + "dtype": "float32", + "component": COMPONENT_LORA, + "source": "checkpoint", + "file": LORA_FILE, + } + ) for record in tensors: - record["file"] = ( - HEAD_FILE if record["component"] == COMPONENT_HEAD else BACKBONE_FILE + record.setdefault( + "file", + HEAD_FILE if record["component"] == COMPONENT_HEAD else BACKBONE_FILE, ) for entry in head_entries: tensors.append( @@ -575,9 +608,10 @@ def plan(name): } ) - # Stream both files while the base handle (the lambdas' data source) + # Stream the files while the base handle (the lambdas' data source) # is still open. files = {BACKBONE_FILE: write_safetensors(out_dir / BACKBONE_FILE, backbone_entries)} + files[LORA_FILE] = write_safetensors(out_dir / LORA_FILE, lora_entries) files[HEAD_FILE] = write_safetensors(out_dir / HEAD_FILE, head_entries) if base_config_path is not None: @@ -592,15 +626,15 @@ def plan(name): manifest = { "format": FORMAT, "sources": sources, - "merge": { - "decision": "merged", - "formula": f"bf16(fp32(W) + {scaling:g} * (B @ A))", + "lora": { + "file": LORA_FILE, + "dtype": "float32", + "rank": lora_config["rank"], + "alpha": lora_config["alpha"], "scaling": scaling, - "lora_rank": merge_config["rank"], - "lora_alpha": merge_config["alpha"], - "merged_tensors": len(classification.lora), - "lora_keys": 2 * len(classification.lora), - "fallback": MERGE_FALLBACK, + "targets": len(classification.lora), + "keys": 2 * len(classification.lora), + "application": LORA_APPLICATION, }, "downcasts": downcasts, "head": { @@ -620,6 +654,13 @@ def plan(name): "Qwen3_5ForConditionalGeneration.from_pretrained(out, dtype=bf16, " "attn_implementation='eager', local_files_only=True).model" ), + "lora": ( + "wrap backbone.language_model in peft get_peft_model(LoraConfig(r=" + f"{lora_config['rank']}, lora_alpha={lora_config['alpha']}, lora_dropout=0, " + "target_modules=, bias='none')), load " + f"{LORA_FILE}'s tensors by key and keep the adapters fp32 at runtime " + "(the application semantics recorded in the lora block)" + ), "head": ( f"{HEAD_FILE}: DecisionHead(hidden_size={head_config['hidden_size']}, " f"projection_dim={head_config['projection_dim']}), fp32, no bias" @@ -647,7 +688,9 @@ def validate_export(out_dir): out_dir = Path(out_dir) # Any unexpected entry — file, directory or otherwise — fails the audit. present = sorted(p.name for p in out_dir.iterdir()) - allowed = sorted([BACKBONE_FILE, HEAD_FILE, MANIFEST_FILE, "config.json"]) + allowed = sorted( + [BACKBONE_FILE, LORA_FILE, HEAD_FILE, MANIFEST_FILE, "config.json"] + ) if present != allowed: extra = sorted(set(present) - set(allowed)) missing = sorted(set(allowed) - set(present)) @@ -658,7 +701,7 @@ def validate_export(out_dir): manifest = json.loads((out_dir / MANIFEST_FILE).read_text(encoding="utf-8")) if manifest.get("format") != FORMAT: raise ValueError(f"manifest format {manifest.get('format')!r} != {FORMAT!r}") - artifact_files = {BACKBONE_FILE, HEAD_FILE, "config.json"} + artifact_files = {BACKBONE_FILE, LORA_FILE, HEAD_FILE, "config.json"} if set(manifest["files"]) != artifact_files: raise ValueError( f"manifest files record {sorted(manifest['files'])} != " @@ -685,7 +728,7 @@ def validate_export(out_dir): raise ValueError(f"manifest tensor entry missing {field!r}: {entry}") if entry["component"] not in COMPONENTS: raise ValueError(f"unknown component {entry['component']!r} for {entry['name']}") - if entry["file"] not in (BACKBONE_FILE, HEAD_FILE): + if entry["file"] not in (BACKBONE_FILE, LORA_FILE, HEAD_FILE): raise ValueError( f"tensor {entry['name']!r} points at unknown file {entry['file']!r}" ) @@ -695,7 +738,7 @@ def validate_export(out_dir): on_disk = set() handles = { file_name: safe_open(out_dir / file_name, framework="pt") - for file_name in (BACKBONE_FILE, HEAD_FILE) + for file_name in (BACKBONE_FILE, LORA_FILE, HEAD_FILE) } try: for file_name, handle in handles.items(): @@ -712,7 +755,7 @@ def validate_export(out_dir): f"{unlisted[0][0]} holds unlisted tensor {unlisted[0][1]!r}" f" ({len(unlisted)} total)" ) - for file_name in (BACKBONE_FILE, HEAD_FILE): + for file_name in (BACKBONE_FILE, LORA_FILE, HEAD_FILE): with safe_open(out_dir / file_name, framework="pt") as handle: for key in sorted(k for f, k in on_disk if f == file_name): slice_ = handle.get_slice(key) @@ -763,12 +806,46 @@ def validate_export(out_dir): if entry["dtype"] != "float32" or entry["component"] != COMPONENT_HEAD: raise ValueError(f"head tensor {name!r} must be an fp32 decision-head tensor") - # The merge decision must match the merged-tensor inventory. - merged = [e for e in manifest["tensors"] if e["component"] == COMPONENT_LORA_MERGED] - if manifest["merge"]["merged_tensors"] != len(merged): + # The LoRA block must match the adapter inventory: every adapter key is + # a listed fp32 tensor in the adapter file that maps onto a listed + # backbone tensor, one A and one B per target, at the declared rank. + lora = manifest["lora"] + if lora["file"] != LORA_FILE: + raise ValueError(f"lora block points at {lora['file']!r}, expected {LORA_FILE!r}") + if lora["scaling"] != lora["alpha"] / lora["rank"]: + raise ValueError("lora scaling is not alpha / rank") + adapters = [e for e in manifest["tensors"] if e["component"] == COMPONENT_LORA] + if lora["keys"] != len(adapters): + raise ValueError( + f"lora block records {lora['keys']} keys, manifest lists {len(adapters)}" + ) + if lora["targets"] * 2 != lora["keys"]: + raise ValueError( + f"lora block records {lora['targets']} targets for {lora['keys']} keys" + ) + by_target = {} + for entry in adapters: + name = entry["name"] + if entry["file"] != LORA_FILE or entry["dtype"] != "float32": + raise ValueError(f"adapter {name!r} must be an fp32 tensor in {LORA_FILE}") + target = lora_key_target(name) + if (BACKBONE_FILE, target) not in listed: + raise ValueError(f"adapter {name!r} targets absent backbone tensor {target!r}") + by_target.setdefault(target, []).append(name) + rank_dim = 0 if name.endswith(PEFT_LORA_SUFFIX_A) else 1 + if entry["shape"][rank_dim] != lora["rank"]: + raise ValueError( + f"adapter {name!r} shape {entry['shape']} contradicts the declared " + f"rank {lora['rank']}" + ) + for target, keys in sorted(by_target.items()): + if len(keys) != 2 or not ( + keys[0].endswith(PEFT_LORA_SUFFIX_A) ^ keys[1].endswith(PEFT_LORA_SUFFIX_A) + ): + raise ValueError(f"target {target!r} does not carry exactly one A and one B") + if len(by_target) != lora["targets"]: raise ValueError( - f"merge block records {manifest['merge']['merged_tensors']} merged tensors, " - f"manifest lists {len(merged)}" + f"lora block records {lora['targets']} targets, adapters cover {len(by_target)}" ) if manifest["parity"] is not None and not isinstance(manifest["parity"], dict): raise ValueError("parity slot must be null or the parity-gate record") @@ -924,7 +1001,7 @@ def export(checkpoint_dir, base_dir, out_dir): weights_file, out_dir, sources=sources, - merge_config={"rank": config["lora_rank"], "alpha": config["lora_alpha"]}, + lora_config={"rank": config["lora_rank"], "alpha": config["lora_alpha"]}, head_config={ "projection_dim": config["projection_dim"], "hidden_size": hidden_size, @@ -954,6 +1031,10 @@ def main(argv=None): for component in sorted(counts): print(f" {component}: {counts[component]} tensors") print(f" downcasts: {len(manifest['downcasts'])} fp32 -> bf16") + print( + f" lora: {manifest['lora']['keys']} unmerged fp32 keys over " + f"{manifest['lora']['targets']} targets" + ) print(f" manifest: {args.out / MANIFEST_FILE}") return 0 diff --git a/tests/valen/test_export.py b/tests/valen/test_export.py index e74e65b8..bfaddb71 100644 --- a/tests/valen/test_export.py +++ b/tests/valen/test_export.py @@ -1,11 +1,11 @@ """Export-tool tests for recipe/valen/export_checkpoint.py. -CPU-only checks of the export's observable contract: the LoRA merge and -FP32->BF16 downcast semantics on synthetic tensors (against the installed -PEFT as the independent reference), checkpoint-key classification, and the -manifest/artifact consistency on a tiny synthetic fixture. The full -determinism run against the real pinned artifacts is env-gated on -VALEN_CKPT / VALEN_BASE. +CPU-only checks of the export's observable contract: the FP32->BF16 +downcast and the unmerged-LoRA application semantics on synthetic tensors +(against the installed PEFT as the independent reference), checkpoint-key +classification, and the manifest/artifact consistency on a tiny synthetic +fixture. The full determinism run against the real pinned artifacts is +env-gated on VALEN_CKPT / VALEN_BASE. PYTHONPATH=src python -m pytest tests/valen -q """ @@ -31,13 +31,16 @@ def test_lora_scaling_is_alpha_over_rank(): assert tool.lora_scaling(rank=8, alpha=4) == 0.5 -def test_merge_matches_peft_merge_bit_exactly(): - """The pinned peft 0.18.1 Linear.merge is the independent source of truth.""" +def test_unmerged_output_matches_peft_layer_forward_bit_exactly(): + """The declared application semantics must reproduce peft 0.18.1's + unmerged Linear.forward on the same tensors: BF16 base GEMM, FP32 LoRA + path, one final rounding.""" + import torch import torch.nn as nn from peft import LoraConfig, get_peft_model - torch.manual_seed(0) + torch.manual_seed(3) linear = nn.Linear(8, 6, bias=False).to(torch.bfloat16) with torch.no_grad(): linear.weight.copy_(torch.randn(6, 8).to(torch.bfloat16)) @@ -57,44 +60,52 @@ def forward(self, x): lora_linear = model.base_model.model.lin with torch.no_grad(): lora_linear.lora_A["default"].weight.copy_(torch.randn(4, 8)) - lora_linear.lora_B["default"].weight.copy_(torch.randn(6, 4) * 0.1) + lora_linear.lora_B["default"].weight.copy_(torch.randn(6, 4) * 0.05) + scaling = lora_linear.scaling["default"] base_weight = linear.weight.detach().clone() lora_a = lora_linear.lora_A["default"].weight.detach().clone() lora_b = lora_linear.lora_B["default"].weight.detach().clone() - scaling = lora_linear.scaling["default"] + probe = torch.randn(32, 8).to(torch.bfloat16) + + model.eval() + with torch.no_grad(): + peft_unmerged = model(probe).detach() - lora_linear.merge(safe_merge=False) - peft_merged = linear.weight.detach().clone() - mine = tool.merge_lora_weight(base_weight, lora_a, lora_b, scaling) + mine = tool.unmerged_lora_output(base_weight, lora_a, lora_b, probe, scaling) assert mine.dtype == torch.bfloat16 - assert torch.equal(mine, peft_merged) + assert torch.equal(mine, peft_unmerged.to(torch.bfloat16)) + +def test_unmerged_output_hand_computed_rounding(): + """bf16(fp32(base GEMM) + fp32 delta) with one rounding of the sum, + hand-computed: base W = 1.0, probe x = 3.0, delta 2*(3*0.0019) = 0.0114 + sits above the 3.0078125 midpoint of the [2, 4) bf16 grid, so the + output rounds to 3.015625.""" -def test_merge_hand_computed_rounding(): - """bf16(fp32(W) + 2*(B@A)) with one rounding of the sum, hand-computed.""" import torch - # bf16 neighbors around 1.0: 1.0 and 1.0078125 (ulp 2^-7); the rounding - # boundary sits at the midpoint 1.00390625. - base = torch.full((1, 1), 1.0, dtype=torch.bfloat16) - # delta = 2 * B@A = 2 * 0.0015 -> sum 1.003 < midpoint -> rounds to 1.0 - small = tool.merge_lora_weight(base, torch.tensor([[0.0015]]), torch.tensor([[1.0]]), 2.0) - assert small.item() == 1.0 - # delta = 2 * B@A = 2 * 0.002 -> sum 1.004 > midpoint -> rounds to 1.0078125 - large = tool.merge_lora_weight(base, torch.tensor([[0.002]]), torch.tensor([[1.0]]), 2.0) - assert large.item() == 1.0078125 + base = torch.tensor([[1.0]], dtype=torch.bfloat16) + lora_a = torch.tensor([[0.0019]], dtype=torch.float32) + lora_b = torch.tensor([[1.0]], dtype=torch.float32) + probe = torch.tensor([[3.0]], dtype=torch.bfloat16) + output = tool.unmerged_lora_output(base, lora_a, lora_b, probe, 2.0) + assert output.dtype == torch.bfloat16 + assert output.item() == 3.015625 -def test_merge_with_zero_lora_b_is_bit_exact_base(): - """Untrained LoRA (zero B, PEFT init) must merge back to the base tensor.""" +def test_unmerged_output_with_zero_lora_b_is_base_gemm(): + """An untrained LoRA (zero B, PEFT init) must reduce to the plain base + GEMM bit-exactly.""" + import torch torch.manual_seed(1) base = torch.randn(6, 8).to(torch.bfloat16) - lora_a = torch.randn(4, 8) - lora_b = torch.zeros(6, 4) - merged = tool.merge_lora_weight(base, lora_a, lora_b, 2.0) - assert torch.equal(merged, base) + probe = torch.randn(16, 8).to(torch.bfloat16) + output = tool.unmerged_lora_output( + base, torch.randn(4, 8), torch.zeros(6, 4), probe, 2.0 + ) + assert torch.equal(output, torch.matmul(probe, base.t())) def test_downcast_rounds_to_nearest_bf16(): @@ -188,6 +199,24 @@ def test_classify_checkpoint_weights_layout(): } +def test_lora_key_target_maps_peft_names_onto_base_tensors(): + for key, target in ( + ( + tool.PEFT_LANGUAGE_PREFIX + "layers.0.mlp.down_proj" + tool.PEFT_LORA_SUFFIX_A, + "model.language_model.layers.0.mlp.down_proj.weight", + ), + ( + tool.PEFT_LANGUAGE_PREFIX + "layers.3.self_attn.q_proj" + tool.PEFT_LORA_SUFFIX_B, + "model.language_model.layers.3.self_attn.q_proj.weight", + ), + ): + assert tool.lora_key_target(key) == target + import pytest + + with pytest.raises(ValueError, match="not a language LoRA key"): + tool.lora_key_target("backbone.visual.merger.norm.weight") + + def test_classify_checkpoint_weights_rejects_unknown_keys(): import pytest @@ -262,7 +291,7 @@ def test_validate_inventory_counts(): "base_tensors": 12, "base_fp32_tensors": 2, } -TINY_MERGE = {"rank": 4, "alpha": 8} +TINY_LORA = {"rank": 4, "alpha": 8} TINY_HEAD = {"projection_dim": 3, "hidden_size": 6, "bias": False} TINY_SOURCES = { "checkpoint": {"revision": "c0ffee", "sha256": "ab" * 32}, @@ -340,7 +369,7 @@ def run_tiny_export(out_dir, input_dir): base_file, Path(out_dir), sources=TINY_SOURCES, - merge_config=TINY_MERGE, + lora_config=TINY_LORA, head_config=TINY_HEAD, expected=TINY_INVENTORY, base_config_path=config_file, @@ -362,17 +391,23 @@ def test_run_export_tiny_fixture_contract(tmp_path): out = tmp_path / "export" manifest = run_tiny_export(out, tmp_path / "in-a") assert sorted(p.name for p in out.iterdir()) == [ - "config.json", "head.safetensors", "model.safetensors", "valen_export.json", + "config.json", + "head.safetensors", + "lora.safetensors", + "model.safetensors", + "valen_export.json", ] # Manifest header fields (the auditable contract). assert manifest["format"] == tool.FORMAT assert manifest["sources"] == TINY_SOURCES - assert manifest["merge"]["decision"] == "merged" - assert manifest["merge"]["scaling"] == 2.0 - assert manifest["merge"]["lora_rank"] == 4 and manifest["merge"]["lora_alpha"] == 8 - assert manifest["merge"]["merged_tensors"] == 3 and manifest["merge"]["lora_keys"] == 6 - assert "fallback" in manifest["merge"] + assert "merge" not in manifest + assert manifest["lora"]["file"] == "lora.safetensors" + assert manifest["lora"]["dtype"] == "float32" + assert manifest["lora"]["rank"] == 4 and manifest["lora"]["alpha"] == 8 + assert manifest["lora"]["scaling"] == 2.0 + assert manifest["lora"]["targets"] == 3 and manifest["lora"]["keys"] == 6 + assert "peft 0.18.1" in manifest["lora"]["application"] assert manifest["downcasts"] == [ "model.language_model.layers.0.linear_attn.A_log", "model.language_model.layers.0.linear_attn.norm.weight", @@ -390,7 +425,7 @@ def test_run_export_tiny_fixture_contract(tmp_path): # Every tensor on disk appears in the manifest with matching metadata. tensors = tensor_map(manifest) - for file_name in (tool.BACKBONE_FILE, tool.HEAD_FILE): + for file_name in (tool.BACKBONE_FILE, tool.LORA_FILE, tool.HEAD_FILE): with safe_open(out / file_name, framework="pt") as f: keys = sorted(f.keys()) for key in keys: @@ -400,9 +435,9 @@ def test_run_export_tiny_fixture_contract(tmp_path): assert entry["dtype"] == { "BF16": "bfloat16", "F32": "float32" }[slice_.get_dtype()] - assert len(manifest["tensors"]) == 12 + 2 - assert [t["name"] for t in manifest["tensors"]] == sorted( - t["name"] for t in manifest["tensors"] + assert len(manifest["tensors"]) == 12 + 6 + 2 + assert [(t["file"], t["name"]) for t in manifest["tensors"]] == sorted( + (t["file"], t["name"]) for t in manifest["tensors"] ) # Component attribution covers all five components. @@ -410,7 +445,8 @@ def test_run_export_tiny_fixture_contract(tmp_path): for entry in manifest["tensors"]: components.setdefault(entry["component"], []).append(entry["name"]) assert set(components) == set(tool.COMPONENTS) - assert len(components["lora-merged"]) == 3 + assert len(components["lora"]) == 6 + assert len(components["backbone"]) == 9 assert components["vision-top"] == [ "model.visual.blocks.20.attn.qkv.weight", "model.visual.blocks.20.norm1.weight", ] @@ -423,22 +459,21 @@ def test_run_export_tiny_fixture_contract(tmp_path): data = (out / file_name).read_bytes() assert record["size"] == len(data) assert record["sha256"] == hashlib.sha256(data).hexdigest() - assert set(manifest["files"]) == {"model.safetensors", "head.safetensors", "config.json"} + assert set(manifest["files"]) == { + "model.safetensors", "lora.safetensors", "head.safetensors", "config.json" + } # The transformations are the declared ones. base, checkpoint = seeded_tiny_inputs() with safe_open(out / tool.BACKBONE_FILE, framework="pt") as f, safe_open( tmp_path / "in-a" / "base.safetensors", framework="pt" ) as original: - merged = f.get_tensor("model.language_model.layers.0.linear_attn.in_proj_qkv.weight") - expected = tool.merge_lora_weight( - original.get_tensor("model.language_model.layers.0.linear_attn.in_proj_qkv.weight"), - checkpoint[tool.PEFT_LANGUAGE_PREFIX + "layers.0.linear_attn.in_proj_qkv" + tool.PEFT_LORA_SUFFIX_A], - checkpoint[tool.PEFT_LANGUAGE_PREFIX + "layers.0.linear_attn.in_proj_qkv" + tool.PEFT_LORA_SUFFIX_B], - 2.0, + # LoRA targets pass through as base tensors, bit-exact and untouched. + target = "model.language_model.layers.0.linear_attn.in_proj_qkv.weight" + assert torch.equal(f.get_tensor(target), original.get_tensor(target)) + assert ( + tensor_map(manifest)[(tool.BACKBONE_FILE, target)]["component"] == "backbone" ) - assert torch.equal(merged, expected) - assert not torch.equal(merged, base["model.language_model.layers.0.linear_attn.in_proj_qkv.weight"]) # Untouched backbone tensors pass through bit-exactly. assert torch.equal( f.get_tensor("mtp.fc.weight"), original.get_tensor("mtp.fc.weight") @@ -461,6 +496,20 @@ def test_run_export_tiny_fixture_contract(tmp_path): f.get_tensor("model.visual.merger.linear_fc1.weight"), checkpoint["backbone.visual.merger.linear_fc1.weight"], ) + # The adapters ride in their own file, FP32 under their PEFT key names. + with safe_open(out / tool.LORA_FILE, framework="pt") as f: + keys = sorted(f.keys()) + assert keys == sorted( + key + for pair in tool.classify_checkpoint_weights( + sorted(checkpoint) + ).lora.values() + for key in pair + ) + for key in keys: + tensor = f.get_tensor(key) + assert tensor.dtype == torch.float32 + assert torch.equal(tensor, checkpoint[key]) with safe_open(out / tool.HEAD_FILE, framework="pt") as f: assert torch.equal(f.get_tensor("head.decision.weight"), checkpoint["head.decision.weight"]) assert f.get_tensor("head.decision.weight").dtype == torch.float32 @@ -474,7 +523,10 @@ def test_run_export_is_deterministic_and_path_independent(tmp_path): first_out, second_out = tmp_path / "export-1", tmp_path / "export-2" run_tiny_export(first_out, tmp_path / "in-a") run_tiny_export(second_out, tmp_path / "in-b-different-name") - for name in ("model.safetensors", "head.safetensors", "config.json", "valen_export.json"): + for name in ( + "model.safetensors", "lora.safetensors", "head.safetensors", "config.json", + "valen_export.json", + ): assert (first_out / name).read_bytes() == (second_out / name).read_bytes(), name # No absolute paths leak into the manifest. manifest_text = (first_out / tool.MANIFEST_FILE).read_text(encoding="utf-8") @@ -512,7 +564,7 @@ def exploding_write(path, entries): base_file, out, sources=TINY_SOURCES, - merge_config=TINY_MERGE, + lora_config=TINY_LORA, head_config=TINY_HEAD, expected=TINY_INVENTORY, base_config_path=config_file, @@ -546,7 +598,7 @@ def exploding_write(path, entries): base_file, out, sources=TINY_SOURCES, - merge_config=TINY_MERGE, + lora_config=TINY_LORA, head_config=TINY_HEAD, expected=TINY_INVENTORY, base_config_path=config_file, @@ -573,7 +625,7 @@ def test_run_export_rejects_unexpected_fp32_base_tensor(tmp_path): directory / "base.safetensors", tmp_path / "out", sources=TINY_SOURCES, - merge_config=TINY_MERGE, + lora_config=TINY_LORA, head_config=TINY_HEAD, expected=dict(TINY_INVENTORY, base_fp32_tensors=3), base_config_path=directory / "config.json", @@ -595,7 +647,7 @@ def test_run_export_rejects_lora_shape_mismatch(tmp_path): base_file, tmp_path / "out", sources=TINY_SOURCES, - merge_config=TINY_MERGE, + lora_config=TINY_LORA, head_config=TINY_HEAD, expected=TINY_INVENTORY, base_config_path=config_file, @@ -612,7 +664,7 @@ def test_run_export_rejects_head_config_mismatch(tmp_path): base_file, tmp_path / "out", sources=TINY_SOURCES, - merge_config=TINY_MERGE, + lora_config=TINY_LORA, head_config=dict(TINY_HEAD, projection_dim=5), expected=TINY_INVENTORY, base_config_path=config_file, @@ -657,12 +709,15 @@ def test_validate_export_detects_tampered_bytes(tmp_path): out = tmp_path / "export" run_tiny_export(out, tmp_path / "in-a") - target = out / tool.BACKBONE_FILE - data = bytearray(target.read_bytes()) - data[-1] ^= 0xFF - target.write_bytes(bytes(data)) - with pytest.raises(ValueError, match="sha256"): - tool.validate_export(out) + for target_name in (tool.BACKBONE_FILE, tool.LORA_FILE): + target = out / target_name + data = bytearray(target.read_bytes()) + data[-1] ^= 0xFF + target.write_bytes(bytes(data)) + with pytest.raises(ValueError, match="sha256"): + tool.validate_export(out) + data[-1] ^= 0xFF + target.write_bytes(bytes(data)) def test_validate_export_detects_manifest_tensor_removal(tmp_path): @@ -671,12 +726,17 @@ def test_validate_export_detects_manifest_tensor_removal(tmp_path): out = tmp_path / "export" run_tiny_export(out, tmp_path / "in-a") - def drop(manifest): - # tensors are sorted by (file, name); mtp.fc.weight is the last entry. - manifest["tensors"] = manifest["tensors"][:-1] + # Removing a lora.safetensors entry leaves an unlisted tensor on disk. + def drop_one(manifest): + manifest["tensors"] = [ + entry + for entry in manifest["tensors"] + if entry["name"] + != tool.PEFT_LANGUAGE_PREFIX + "layers.0.mlp.gate_proj" + tool.PEFT_LORA_SUFFIX_A + ] - rewrite_manifest(out, drop) - with pytest.raises(ValueError, match="mtp.fc.weight"): + rewrite_manifest(out, drop_one) + with pytest.raises(ValueError, match="unlisted tensor"): tool.validate_export(out) @@ -708,6 +768,35 @@ def wrong_dim(manifest): tool.validate_export(out) +def test_validate_export_detects_lora_block_drift(tmp_path): + import pytest + + rounds = iter((1, 2, 3)) + + def check(mutation, pattern): + out = tmp_path / f"export-{next(rounds)}" + run_tiny_export(out, tmp_path / f"in-{out.name}") + rewrite_manifest(out, mutation) + with pytest.raises(ValueError, match=pattern): + tool.validate_export(out) + + def wrong_keys(manifest): + manifest["lora"]["keys"] = 5 + + check(wrong_keys, "lora block records") + + def wrong_scaling(manifest): + manifest["lora"]["scaling"] = 3.0 + + check(wrong_scaling, "lora scaling is not alpha / rank") + + def wrong_rank(manifest): + manifest["lora"]["rank"] = 2 + manifest["lora"]["alpha"] = 4 + + check(wrong_rank, "contradicts the declared rank") + + def test_validate_export_detects_extra_files_and_forgotten_entries(tmp_path): import pytest @@ -719,15 +808,15 @@ def test_validate_export_detects_extra_files_and_forgotten_entries(tmp_path): (out / "surprise.bin").unlink() def drop_file_record(manifest): - del manifest["files"][tool.HEAD_FILE] + del manifest["files"][tool.LORA_FILE] rewrite_manifest(out, drop_file_record) - with pytest.raises(ValueError, match="head.safetensors"): + with pytest.raises(ValueError, match="lora.safetensors"): tool.validate_export(out) def test_validate_export_rejects_extra_directories(tmp_path): - """The "exactly four files" audit must reject any unexpected entry, not + """The "exactly five files" audit must reject any unexpected entry, not only unexpected regular files — otherwise a stray subdirectory passes.""" import pytest @@ -940,24 +1029,32 @@ def test_real_artifact_export_is_deterministic(tmp_path): runs.append((out, manifest)) (first, manifest_one), (second, manifest_two) = runs - for name in (tool.BACKBONE_FILE, tool.HEAD_FILE, "config.json", tool.MANIFEST_FILE): + for name in ( + tool.BACKBONE_FILE, tool.LORA_FILE, tool.HEAD_FILE, "config.json", + tool.MANIFEST_FILE, + ): assert (first / name).read_bytes() == (second / name).read_bytes(), name # The pinned inventory, end to end. counts = {} for entry in manifest_one["tensors"]: counts[entry["component"]] = counts.get(entry["component"], 0) + 1 - # 632 base tensors: 186 merged, 48 vision-top and 6 merger replaced, - # 392 passing through (36 of them downcast from fp32). + # 632 base tensors: 48 vision-top and 6 merger replaced, the remaining + # 578 passing through verbatim (36 of them downcast from fp32) — the + # 186 LoRA targets among them, untouched. assert counts == { - "backbone": 392, - "lora-merged": 186, + "backbone": 578, + "lora": 372, "vision-top": 48, "merger": 6, "decision-head": 2, } assert len(manifest_one["downcasts"]) == 36 - assert manifest_one["merge"]["scaling"] == 2.0 + lora = manifest_one["lora"] + assert lora["rank"] == 32 and lora["alpha"] == 64 + assert lora["scaling"] == 2.0 + assert lora["targets"] == 186 and lora["keys"] == 372 + assert lora["dtype"] == "float32" assert manifest_one["head"]["projection_dim"] == 256 assert manifest_one["head"]["hidden_size"] == 2048 assert manifest_one["sources"]["checkpoint"]["sha256"] == ( From 247ad8c63a58b5120e463c645c68b580cff0f7cd Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 18:58:44 +0800 Subject: [PATCH 21/32] valen: rework the parity gate for the unmerged export --- recipe/valen/test_export_reference.py | 4 +- recipe/valen/verify_export_parity.py | 270 ++++++-------------------- tests/valen/test_export_parity.py | 214 ++------------------ 3 files changed, 78 insertions(+), 410 deletions(-) diff --git a/recipe/valen/test_export_reference.py b/recipe/valen/test_export_reference.py index cc52e0db..51483a45 100644 --- a/recipe/valen/test_export_reference.py +++ b/recipe/valen/test_export_reference.py @@ -100,11 +100,13 @@ def validate_artifact_set(results, export_dir): for entry in manifest["tensors"]: counts[entry["component"]] = counts.get(entry["component"], 0) + 1 parity = manifest["parity"] + lora = manifest["lora"] summary = ( f"{manifest['format']}, {len(manifest['tensors'])} tensors " + ", ".join(f"{counts[c]} {c}" for c in sorted(counts)) + f", {len(manifest['downcasts'])} fp32->bf16 downcasts" - + f", merge {manifest['merge']['decision']}" + + f", lora unmerged r={lora['rank']} alpha={lora['alpha']}" + + f" ({lora['keys']} fp32 keys / {lora['targets']} targets)" + f", parity slot {'filled' if parity is not None else 'not recorded'}" ) record(results, f"validate {export_dir}", True, summary) diff --git a/recipe/valen/verify_export_parity.py b/recipe/valen/verify_export_parity.py index 78ab51af..1fa747d5 100644 --- a/recipe/valen/verify_export_parity.py +++ b/recipe/valen/verify_export_parity.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """Prove the exported Valen artifact set answers like the original checkpoint. -Two model stacks are loaded serially — -only one lives on the GPU at a time, each in its own subprocess so the -~6.6 GiB of VRAM is fully released between loads: +Two model stacks are loaded serially — only one lives on the GPU at a time, +each in its own subprocess so the ~6.6 GiB of VRAM is fully released between +loads: - original: the pinned reference stack exactly as the worker builds it (``ValenExecutor`` -> ``valen.modeling.model.build_model`` + @@ -12,10 +12,14 @@ from ``checkpoint.pt``); - exported: the consumption path the export manifest documents — ``Qwen3_5ForConditionalGeneration.from_pretrained(weights/valen-export, - dtype=bf16, eager).model`` for the merged backbone plus the decision head - rebuilt from ``head.safetensors`` as the pinned ``DecisionHead`` (2048 -> - 256, FP32, no bias), loaded through the pinned ``Valen`` class so the - forward path is the reference's, not a second invention. + dtype=bf16, eager).model`` for the backbone, the language LoRA re-applied + through the same peft build the pinned reference uses (every language + Linear wrapped at the manifest-declared rank and alpha, the 372 FP32 + tensors of ``lora.safetensors`` loaded by key, adapters kept FP32), and + the decision head rebuilt from ``head.safetensors`` as the pinned + ``DecisionHead`` (2048 -> 256, FP32, no bias), all through the pinned + ``Valen`` class so the forward path is the reference's, not a second + invention. Both stacks answer the recipe's existing text and image ``choice`` fixtures (``compare_reference.py``'s text example and ``example-request.json``, @@ -23,18 +27,10 @@ The gate requires exact answer agreement and a probability delta <= 1e-6 (the tolerance the reference serving already declared). -The script also measures the merge drift on the real tensors: for each of -the 186 merged keys it compares the unmerged PEFT application (base W plus -the FP32 ``2*(B@A)`` delta on the activations, one final BF16 rounding — -the reference's semantics) against the exported merged BF16 weight's GEMM, -with a fixed probe (256 BF16 standard-normal rows, seed 0, single-threaded -CPU), and aggregates max/mean. - Finally it fills the manifest's ``parity`` slot in ``weights/valen-export/valen_export.json`` with the verdict (status, device, -cases, agreement, max probability delta, merge drift aggregate, evidence -pointer) — a reproducible command output, never a hand edit — and -re-validates the artifact set. +cases, agreement, max probability delta, evidence pointer) — a reproducible +command output, never a hand edit — and re-validates the artifact set. Usage (from the repository root, on the GPU host): @@ -42,15 +38,13 @@ --valen-source weights/Valen --checkpoint weights/Valen-Preview-0923 \ --base weights/Qwen3.5-2B --export weights/valen-export --device cuda -Exit status 0 means the gate passed; 1 means it failed (the documented -fallback — export the FP32 LoRA separately and serve unmerged — applies). +Exit status 0 means the gate passed; 1 means it failed. """ from __future__ import annotations import argparse import json -import math import subprocess import sys from pathlib import Path @@ -63,100 +57,10 @@ import export_checkpoint as exporter # noqa: E402 PROBABILITY_TOLERANCE = 1e-6 -DRIFT_ROWS = 256 -DRIFT_SEED = 0 STAGE_SENTINEL = "VERIFY-PARITY-STAGE-RESULT " PARITY_CASES = ("text", "image") -def merge_lora_weight(base_weight, lora_a, lora_b, scaling): - """The export tool's merge math (bit-equal to peft 0.18.1's ``Linear.merge``).""" - - return exporter.merge_lora_weight(base_weight, lora_a, lora_b, scaling) - - -# --- the drift ruler: bf16 spacing at a magnitude --- - - -def bf16_ulp_at(magnitude): - """The BF16 grid spacing covering ``magnitude``. - - bf16 keeps 8 significant bits, so values in ``[m * 2^e)`` with - ``m in [0.5, 1)`` (``math.frexp``) sit on a ``2^(e-8)`` grid: spacing - 2^-7 around 1.0, 2^-6 on [2, 4), 2^-5 on [4, 8). - """ - - if magnitude <= 0.0: - return 0.0 - _, exponent = math.frexp(magnitude) - return 2.0 ** (exponent - 8) - - -def module_output_drift(base_weight, lora_a, lora_b, merged_weight, scaling, probe): - """Output drift of one adapted module: unmerged (PEFT, the reference) - vs merged (the exported BF16 weight). - - Unmerged arm — peft 0.18.1 ``Linear.forward`` semantics: a BF16 base - GEMM, the ``scaling * (B(A(x)))`` delta computed and added in FP32, one - final rounding back to BF16. Merged arm: the BF16 GEMM against - ``bf16(W + scaling * B @ A)``. ``probe`` is a BF16 activation batch of - shape ``(rows, in_features)``. - """ - - import torch - - if base_weight.dtype != torch.bfloat16 or merged_weight.dtype != torch.bfloat16: - raise ValueError("base and merged weights must be bf16") - y_base = torch.matmul(probe, base_weight.t()) - delta = scaling * ((probe.float() @ lora_a.t()) @ lora_b.t()) - y_unmerged = (y_base.float() + delta).to(torch.bfloat16) - y_merged = torch.matmul(probe, merged_weight.t()) - - diff = (y_merged.float() - y_unmerged.float()).abs() - max_abs = float(diff.max()) - max_output = float(y_unmerged.float().abs().max()) - ulp = bf16_ulp_at(max_output) - return { - "max_abs_diff": max_abs, - "max_output_abs": max_output, - "max_rel_diff": (max_abs / max_output) if max_output > 0.0 else 0.0, - "bf16_ulp": ulp, - "ulp_ratio": (max_abs / ulp) if ulp > 0.0 else 0.0, - "bitexact_fraction": float((y_merged == y_unmerged).float().mean()), - } - - -def aggregate_drift(records): - """Max/mean drift across the adapted modules, worst offenders named. - - ``records`` is a list of ``(tensor name, metrics)`` pairs from - :func:`module_output_drift`; ties break deterministically by name. - """ - - if not records: - raise ValueError("no drift records to aggregate") - ordered = sorted(records, key=lambda record: record[0]) - count = len(ordered) - worst_abs = ordered[0] - worst_rel = ordered[0] - for record in ordered[1:]: - if record[1]["max_abs_diff"] > worst_abs[1]["max_abs_diff"]: - worst_abs = record - if record[1]["max_rel_diff"] > worst_rel[1]["max_rel_diff"]: - worst_rel = record - return { - "modules": count, - "max_abs_diff": max(metrics["max_abs_diff"] for _, metrics in ordered), - "mean_abs_diff": sum(m["max_abs_diff"] for _, m in ordered) / count, - "max_rel_diff": max(m["max_rel_diff"] for _, m in ordered), - "mean_rel_diff": sum(m["max_rel_diff"] for _, m in ordered) / count, - "max_ulp_ratio": max(m["ulp_ratio"] for _, m in ordered), - "mean_bitexact_fraction": sum(m["bitexact_fraction"] for _, m in ordered) / count, - "worst_abs_module": worst_abs[0], - "worst_rel_module": worst_rel[0], - } - - # --- the gate's verdict: one case answered by both stacks --- @@ -242,7 +146,6 @@ def build_parity_record( exact_answer_agreement, max_probability_delta, tolerance, - drift, evidence, ): """Assemble the record written into the manifest's ``parity`` slot.""" @@ -257,7 +160,6 @@ def build_parity_record( "cases": cases, "exact_answer_agreement": bool(exact_answer_agreement), "max_probability_delta": max_probability_delta, - "merge_drift": drift, "evidence": evidence, } @@ -297,12 +199,15 @@ def load_original_stack(checkpoint, base, device): return executor -def load_exported_stack(export_dir, projection_dim, device): +def load_exported_stack(export_dir, manifest, device): """The exported artifact set loaded through its documented consumption - path: ``from_pretrained`` backbone + the head rebuilt from - ``head.safetensors`` into the pinned ``Valen`` class.""" + path: the ``from_pretrained`` backbone, the language LoRA re-applied + through the same peft build the pinned reference uses, and the head + rebuilt from ``head.safetensors`` into the pinned ``Valen`` class.""" import torch + import torch.nn as nn + from peft import LoraConfig, get_peft_model from safetensors import safe_open from transformers import Qwen3_5ForConditionalGeneration @@ -324,7 +229,41 @@ def load_exported_stack(export_dir, projection_dim, device): # Only container.model is served; drop the vocabulary/generation wrapper. del container backbone.requires_grad_(False) - model = Valen(backbone, projection_dim) + + # The pinned build path's own adaptation: every language Linear wrapped + # at the manifest-declared rank and alpha, adapters kept fp32 at runtime. + lora = manifest["lora"] + targets = [ + name + for name, module in backbone.language_model.named_modules() + if isinstance(module, nn.Linear) + ] + if len(targets) != lora["targets"]: + raise ValueError( + f"backbone enumerates {len(targets)} language linears, the manifest " + f"declares {lora['targets']}" + ) + backbone.language_model = get_peft_model( + backbone.language_model, + LoraConfig( + r=lora["rank"], + lora_alpha=lora["alpha"], + lora_dropout=0.0, + target_modules=targets, + bias="none", + ), + ) + model = Valen(backbone, manifest["head"]["projection_dim"]) + with safe_open(Path(export_dir) / lora["file"], framework="pt") as handle: + adapters = {key: handle.get_tensor(key) for key in sorted(handle.keys())} + expected = {name for name, _ in model.named_parameters() if "lora_" in name} + if set(adapters) != expected: + raise ValueError( + "lora.safetensors keys do not match the wrapped model's adapters: " + f"file-only {sorted(set(adapters) - expected)[:2]}, " + f"model-only {sorted(expected - set(adapters))[:2]}" + ) + model.load_state_dict(adapters, strict=False) with safe_open(Path(export_dir) / exporter.HEAD_FILE, framework="pt") as handle: head_state = { key[len(exporter.HEAD_PREFIX):]: handle.get_tensor(key) @@ -433,9 +372,7 @@ def run_stage(args): manifest = json.loads( (Path(args.export) / exporter.MANIFEST_FILE).read_text(encoding="utf-8") ) - model = load_exported_stack( - Path(args.export), manifest["head"]["projection_dim"], args.device - ) + model = load_exported_stack(Path(args.export), manifest, args.device) device = str(next(model.parameters()).device) hardware = None @@ -498,63 +435,6 @@ def run_stage_subprocess(args, stage): return payload -# --- the merge-drift measurement on the real tensors --- - - -def measure_merge_drift(checkpoint_dir, base_dir, export_dir, *, rows=DRIFT_ROWS, seed=DRIFT_SEED): - """Per-module drift of the 186 merged keys on the real tensors. - - The unmerged arm uses the base file's BF16 weight plus the checkpoint's - FP32 LoRA (reference semantics); the merged arm uses the tensor actually - sitting in the exported ``model.safetensors``, cross-checked bit-exactly - against the export tool's merge math so the measurement can never run - against a silently different weight. Probe protocol: ``rows`` BF16 - standard-normal activations per module, one generator seeded once, - modules in sorted name order, CPU single thread. - """ - - import torch - from safetensors import safe_open - - torch.set_grad_enabled(False) - torch.set_num_threads(1) - - payload = torch.load( - Path(checkpoint_dir) / exporter.CHECKPOINT_FILE, - map_location="cpu", - weights_only=False, - mmap=True, - ) - config = payload["config"] - scaling = exporter.lora_scaling(config["lora_rank"], config["lora_alpha"]) - classification = exporter.classify_checkpoint_weights(payload["weights"]) - exporter.validate_inventory(classification, exporter.PINNED_INVENTORY) - - base_file = exporter.base_weights_file(base_dir) - records = [] - torch.manual_seed(seed) - with ( - safe_open(base_file, framework="pt", device="cpu") as base, - safe_open(Path(export_dir) / exporter.BACKBONE_FILE, framework="pt", device="cpu") as exported, - ): - for name, (a_key, b_key) in sorted(classification.lora.items()): - base_weight = base.get_tensor(name) - lora_a = payload["weights"][a_key] - lora_b = payload["weights"][b_key] - merged = exported.get_tensor(name) - expected = merge_lora_weight(base_weight, lora_a, lora_b, scaling) - if not torch.equal(merged, expected): - raise ValueError( - f"exported tensor {name!r} is not bf16(W + {scaling:g}*(B@A)) of " - "the given base and checkpoint" - ) - probe = torch.randn(rows, base_weight.shape[1]).to(torch.bfloat16) - records.append( - (name, module_output_drift(base_weight, lora_a, lora_b, merged, scaling, probe)) - ) - return records, scaling - - # --- reporting --- @@ -576,20 +456,6 @@ def format_case_report(verdict, tolerance): return lines -def format_drift_report(aggregate, *, rows, seed, scaling): - lines = [ - f"merge drift: {aggregate['modules']} adapted modules " - f"(probe {rows}x bf16 rows, seed {seed}, scaling {scaling:g}, cpu single thread)", - f" max output delta: {aggregate['max_abs_diff']:.6e} " - f"(mean {aggregate['mean_abs_diff']:.6e}), worst {aggregate['worst_abs_module']}", - f" max relative output drift: {aggregate['max_rel_diff']:.6e} " - f"(mean {aggregate['mean_rel_diff']:.6e}), worst {aggregate['worst_rel_module']}", - f" max ulp ratio: {aggregate['max_ulp_ratio']:.6g}, " - f"mean bit-exact fraction: {aggregate['mean_bitexact_fraction']:.6f}", - ] - return lines - - def evidence_pointer(): return ( "recipe/valen/reference.md (export parity block); reproduce with " @@ -606,8 +472,6 @@ def parse_args(argv=None): parser.add_argument("--export", required=True, type=Path, help="exported artifact directory") parser.add_argument("--device", default="cuda") parser.add_argument("--tolerance", type=float, default=PROBABILITY_TOLERANCE) - parser.add_argument("--rows", type=int, default=DRIFT_ROWS, help="drift probe rows per module") - parser.add_argument("--seed", type=int, default=DRIFT_SEED, help="drift probe seed") parser.add_argument("--stage", choices=("original", "export"), default=None, help=argparse.SUPPRESS) parser.add_argument("--force", action="store_true", help="replace an existing parity record") return parser.parse_args(argv) @@ -616,7 +480,7 @@ def parse_args(argv=None): def verify_sources_match_manifest(args, manifest): """The parity evidence only holds for the artifacts the export recorded. - Verifies the checkpoint sha, the base revision and — because 392 tensors + Verifies the checkpoint sha, the base revision and — because 578 tensors pass through the export byte by byte — the base weights shard digests themselves against the manifest's recorded ``sources.base.weights_sha256``. """ @@ -663,11 +527,6 @@ def main(argv=None): for name in PARITY_CASES ] - records, scaling = measure_merge_drift( - args.checkpoint, args.base, export_dir, rows=args.rows, seed=args.seed - ) - aggregate = aggregate_drift(records) - status = "passed" if all(verdict["passed"] for verdict in verdicts) else "failed" record = build_parity_record( status=status, @@ -689,17 +548,6 @@ def main(argv=None): exact_answer_agreement=all(v["exact_answer_agreement"] for v in verdicts), max_probability_delta=max(v["max_probability_delta"] for v in verdicts), tolerance=args.tolerance, - drift={ - "method": ( - "per adapted module on real tensors: unmerged = bf16 GEMM(W) plus " - "fp32 2*(B@A) delta on activations with one final bf16 rounding " - "(peft 0.18.1 Linear.forward); merged = bf16 GEMM(bf16(W + 2*(B@A))) " - "from the export; probe {rows}x bf16 standard-normal rows, seed {seed}, " - "cpu single thread" - ).format(rows=args.rows, seed=args.seed), - "modules": aggregate["modules"], - "aggregate": aggregate, - }, evidence=evidence_pointer(), ) fill_parity_slot(export_dir / exporter.MANIFEST_FILE, record, force=args.force) @@ -708,12 +556,8 @@ def main(argv=None): for verdict in verdicts: print("\n".join(format_case_report(verdict, args.tolerance))) print() - print("\n".join(format_drift_report(aggregate, rows=args.rows, seed=args.seed, scaling=scaling))) - print() print(f"device: {record['device']} ({record['hardware']})") print(f"parity slot filled: {export_dir / exporter.MANIFEST_FILE}") - if status == "failed": - print(f"merge fallback: {manifest['merge']['fallback']}") print("verify_export_parity: ok" if status == "passed" else "verify_export_parity: FAILED") return 0 if status == "passed" else 1 diff --git a/tests/valen/test_export_parity.py b/tests/valen/test_export_parity.py index 9cb03dfa..bc089876 100644 --- a/tests/valen/test_export_parity.py +++ b/tests/valen/test_export_parity.py @@ -1,11 +1,9 @@ """Parity-gate tests for recipe/valen/verify_export_parity.py. CPU-only checks of the export-parity tool's observable contract: the -merge-drift measurement (PEFT unmerged vs merged BF16 semantics) on -hand-computed tensors, the drift aggregation, the original-vs-exported -case comparison verdicts, and the manifest parity-slot filling. The full -parity gate against the real pinned artifacts is env-gated on -VALEN_CKPT / VALEN_BASE / VALEN_SRC plus CUDA. +original-vs-exported case comparison verdicts and the manifest parity-slot +filling. The full parity gate against the real pinned artifacts is +env-gated on VALEN_CKPT / VALEN_BASE / VALEN_SRC plus CUDA. PYTHONPATH=src python -m pytest tests/valen -q """ @@ -26,156 +24,6 @@ import verify_export_parity as tool # noqa: E402 -# --- the BF16 ulp ruler --- - -def test_bf16_ulp_at_known_magnitudes(): - # bf16 keeps 8 significant bits, so values in [2^e, 2^(e+1)) sit on a - # 2^(e-7) grid: spacing 2^-7 at 1.0, 2^-6 on [2, 4), 2^-5 on [4, 8). - assert tool.bf16_ulp_at(1.0) == 2.0**-7 - assert tool.bf16_ulp_at(1.0078125) == 2.0**-7 - assert tool.bf16_ulp_at(3.015625) == 2.0**-6 - assert tool.bf16_ulp_at(6.0) == 2.0**-5 - assert tool.bf16_ulp_at(0.25) == 2.0**-9 - assert tool.bf16_ulp_at(16.0) == 2.0**-3 - - -# --- per-module output drift: unmerged (PEFT) vs merged BF16 GEMM --- - -def test_module_output_drift_zero_b_adapter_is_bit_exact(): - """An untrained LoRA (zero B) leaves both outputs bit-identical.""" - import torch - - torch.manual_seed(0) - base = torch.randn(6, 8).to(torch.bfloat16) - merged = tool.merge_lora_weight(base, torch.randn(4, 8), torch.zeros(6, 4), 2.0) - probe = torch.randn(16, 8).to(torch.bfloat16) - metrics = tool.module_output_drift( - base, torch.randn(4, 8), torch.zeros(6, 4), merged, 2.0, probe - ) - assert metrics["max_abs_diff"] == 0.0 - assert metrics["bitexact_fraction"] == 1.0 - assert metrics["max_rel_diff"] == 0.0 - - -def test_module_output_drift_hand_computed_one_ulp(): - """The rounding-order split, hand-computed on a 1x1 module. - - base W = 1.0, probe x = 3.0 (both bf16), delta weight 2*(B@A) = 0.0038: - - - merged: W' = bf16(1.0038) = 1.0 (below the 1.00390625 midpoint), so - y_merged = 3.0 exactly; - - unmerged (PEFT): y = bf16(3.0 + 3*0.0038) = bf16(3.0114) = 3.015625 - (above the 3.0078125 midpoint of the [2, 4) grid). - - So the outputs differ by exactly one bf16 ulp of the output. - """ - import torch - - base = torch.tensor([[1.0]], dtype=torch.bfloat16) - lora_a = torch.tensor([[0.0019]], dtype=torch.float32) - lora_b = torch.tensor([[1.0]], dtype=torch.float32) - merged = tool.merge_lora_weight(base, lora_a, lora_b, 2.0) - assert merged.item() == 1.0 - - probe = torch.tensor([[3.0]], dtype=torch.bfloat16) - metrics = tool.module_output_drift(base, lora_a, lora_b, merged, 2.0, probe) - assert metrics["max_abs_diff"] == pytest.approx(0.015625) - assert metrics["max_output_abs"] == pytest.approx(3.015625) - assert metrics["bf16_ulp"] == pytest.approx(0.015625) - assert metrics["ulp_ratio"] == pytest.approx(1.0) - assert metrics["max_rel_diff"] == pytest.approx(0.015625 / 3.015625) - assert metrics["bitexact_fraction"] == 0.0 - - -def test_module_output_drift_matches_peft_layer_forward(): - """The unmerged arm must reproduce peft 0.18.1's Linear.forward on the - same tensors: bf16 base GEMM, FP32 LoRA path, one final rounding.""" - - import torch - import torch.nn as nn - from peft import LoraConfig, get_peft_model - - torch.manual_seed(3) - linear = nn.Linear(8, 6, bias=False).to(torch.bfloat16) - with torch.no_grad(): - linear.weight.copy_(torch.randn(6, 8).to(torch.bfloat16)) - - class Wrapped(nn.Module): - def __init__(self): - super().__init__() - self.lin = linear - - def forward(self, x): - return self.lin(x) - - model = get_peft_model( - Wrapped(), - LoraConfig(r=4, lora_alpha=8, lora_dropout=0.0, target_modules=["lin"], bias="none"), - ) - lora_linear = model.base_model.model.lin - with torch.no_grad(): - lora_linear.lora_A["default"].weight.copy_(torch.randn(4, 8)) - lora_linear.lora_B["default"].weight.copy_(torch.randn(6, 4) * 0.05) - scaling = lora_linear.scaling["default"] - base_weight = linear.weight.detach().clone() - lora_a = lora_linear.lora_A["default"].weight.detach().clone() - lora_b = lora_linear.lora_B["default"].weight.detach().clone() - merged = tool.merge_lora_weight(base_weight, lora_a, lora_b, scaling) - probe = torch.randn(32, 8).to(torch.bfloat16) - - model.eval() - with torch.no_grad(): - peft_unmerged = model(probe).detach() - - metrics = tool.module_output_drift(base_weight, lora_a, lora_b, merged, scaling, probe) - assert metrics["max_abs_diff"] > 0.0 - assert metrics["bitexact_fraction"] < 1.0 - # The hand-computed unmerged arm must match peft bit-exactly. - y_base = torch.matmul(probe, base_weight.t()) - delta = scaling * ((probe.float() @ lora_a.t()) @ lora_b.t()) - y_unmerged = (y_base.float() + delta).to(torch.bfloat16) - assert torch.equal(y_unmerged, peft_unmerged.to(torch.bfloat16)) - - -# --- drift aggregation across the adapted modules --- - -def drift_metrics(max_abs, max_out, ulp, bitexact): - return { - "max_abs_diff": max_abs, - "max_output_abs": max_out, - "max_rel_diff": max_abs / max_out, - "bf16_ulp": ulp, - "ulp_ratio": max_abs / ulp, - "bitexact_fraction": bitexact, - } - - -def test_aggregate_drift_reports_max_mean_and_worst_modules(): - records = [ - ("layers.0.mlp.down_proj.weight", drift_metrics(0.03125, 6.0, 0.03125, 0.5)), - ("layers.0.mlp.gate_proj.weight", drift_metrics(0.015625, 3.0, 0.015625, 0.6)), - ("layers.3.self_attn.q_proj.weight", drift_metrics(0.03125, 8.0, 0.0625, 0.4)), - ] - aggregate = tool.aggregate_drift(records) - assert aggregate["modules"] == 3 - assert aggregate["max_abs_diff"] == pytest.approx(0.03125) - assert aggregate["mean_abs_diff"] == pytest.approx((0.03125 + 0.015625 + 0.03125) / 3) - assert aggregate["max_rel_diff"] == pytest.approx(0.03125 / 6.0) - assert aggregate["mean_rel_diff"] == pytest.approx( - (0.03125 / 6.0 + 0.015625 / 3.0 + 0.03125 / 8.0) / 3 - ) - assert aggregate["max_ulp_ratio"] == pytest.approx(1.0) - assert aggregate["mean_bitexact_fraction"] == pytest.approx(0.5) - # Ties break deterministically by module name. - assert aggregate["worst_abs_module"] == "layers.0.mlp.down_proj.weight" - assert aggregate["worst_rel_module"] == "layers.0.mlp.down_proj.weight" - - -def test_aggregate_drift_requires_records(): - with pytest.raises(ValueError, match="no drift records"): - tool.aggregate_drift([]) - - # --- the original-vs-exported case comparison (the gate's verdict) --- def case_side(probs, keys=("refund", "wait"), logical=48, compute=48): @@ -257,20 +105,18 @@ def test_build_parity_record_covers_the_documented_slot_shape(): hardware="NVIDIA GeForce RTX 5060 Laptop GPU (sm_120)", cases=[{"case": "text", "passed": True}], exact_answer_agreement=True, - max_probability_delta=2.7e-08, + max_probability_delta=0.0, tolerance=1e-6, - drift={"modules": 186, "max_abs_diff": 0.03125}, evidence="recipe/valen/reference.md export-parity block", ) # Every key the manifest's parity_slot_shape documents must be present. - assert set(record) >= { - "status", "device", "cases", "exact_answer_agreement", - "max_probability_delta", "merge_drift", "evidence", + assert set(record) == { + "status", "device", "hardware", "tolerance", "cases", + "exact_answer_agreement", "max_probability_delta", "evidence", } assert record["status"] == "passed" - assert record["max_probability_delta"] == 2.7e-08 + assert record["max_probability_delta"] == 0.0 assert record["tolerance"] == 1e-6 - assert record["merge_drift"] == {"modules": 186, "max_abs_diff": 0.03125} def test_build_parity_record_rejects_unknown_status(): @@ -283,14 +129,13 @@ def test_build_parity_record_rejects_unknown_status(): exact_answer_agreement=True, max_probability_delta=0.0, tolerance=1e-6, - drift={}, evidence="", ) def minimal_manifest(path, parity=None): manifest = { - "format": "valen-checkpoint-export/1", + "format": "valen-checkpoint-export/2", "parity": parity, "parity_slot_shape": {"status": "passed | failed"}, } @@ -309,7 +154,6 @@ def parity_record(status="passed", delta=1e-8): exact_answer_agreement=status == "passed", max_probability_delta=delta, tolerance=1e-6, - drift={"modules": 186}, evidence="recipe/valen/reference.md", ) @@ -417,7 +261,7 @@ def test_verify_sources_rejects_base_revision_drift(tmp_path): def test_verify_sources_rejects_tampered_base_shard_digest(tmp_path): """The base weights themselves must be re-hashed against the manifest's - recorded per-shard digests: 392 tensors pass through the export byte by + recorded per-shard digests: 578 tensors pass through the export byte by byte, so a silently swapped base would poison the parity verdict.""" import pytest @@ -443,15 +287,9 @@ def test_verify_sources_rejects_swapped_base_shard_bytes(tmp_path): # --- the full parity gate against the real pinned artifacts --- # Set VALEN_CKPT, VALEN_BASE and VALEN_SRC (the recipe reference.md artifact # exports) on a CUDA host to run: it exports a fresh artifact set, then runs -# the two serial model loads (original checkpoint stack, exported stack), -# the 186-module merge-drift measurement and the manifest parity fill. -# -# The gate's exit status is evidence, not a regression invariant: on the -# pinned artifacts the declared 1e-6 tolerance is exceeded by the measured -# merge drift (recorded honestly in recipe/valen/reference.md), so the test -# asserts the verdict's internal consistency — the recorded status, deltas, -# agreement and drift must match what the tool actually measured, and the -# exact-answer agreement of the pinned artifacts must hold. +# the two serial model loads (original checkpoint stack, exported stack) and +# fills the manifest parity slot. The unmerged export is expected to pass the +# declared gate; the test asserts the verdict and its internal consistency. def test_real_artifact_export_parity_gate(tmp_path): import subprocess @@ -484,36 +322,20 @@ def test_real_artifact_export_parity_gate(tmp_path): text=True, timeout=3600, ) - assert result.returncode in (0, 1), result.stdout[-4000:] + result.stderr[-4000:] + assert result.returncode == 0, result.stdout[-4000:] + result.stderr[-4000:] manifest = exporter.validate_export(export_dir) parity = manifest["parity"] + assert parity["status"] == "passed" assert parity["device"].startswith("cuda") assert parity["tolerance"] == 1e-6 assert {case["case"] for case in parity["cases"]} == {"text", "image"} - # The pinned artifacts keep agreeing on the discrete answers. assert parity["exact_answer_agreement"] is True assert all(case["exact_answer_agreement"] for case in parity["cases"]) - # The recorded verdict is internally consistent with the measurements. assert parity["max_probability_delta"] == max( case["max_probability_delta"] for case in parity["cases"] ) - expected_status = ( - "passed" - if all(case["passed"] for case in parity["cases"]) - else "failed" - ) - assert parity["status"] == expected_status + assert parity["max_probability_delta"] <= parity["tolerance"] for case in parity["cases"]: - case_passes = ( - case["exact_answer_agreement"] - and case["max_probability_delta"] <= parity["tolerance"] - ) - assert case["passed"] is case_passes - if not case_passes: - assert any("tolerance" in problem for problem in case["problems"]) - assert parity["merge_drift"]["modules"] == 186 - aggregate = parity["merge_drift"]["aggregate"] - assert aggregate["modules"] == 186 - assert 0.0 < aggregate["max_abs_diff"] - assert 0.0 < aggregate["max_ulp_ratio"] <= 1.0 + assert case["passed"] is True + assert case["problems"] == [] From cfce7babe53294f53d213cf587e528b49364ac39 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 18:58:44 +0800 Subject: [PATCH 22/32] valen: document the unmerged artifact set and record the passing parity run --- recipe/valen/export.md | 90 +++++++++++++++++++++++--------------- recipe/valen/reference.md | 77 ++++++++++++++------------------ src/models/valen/README.md | 5 ++- 3 files changed, 89 insertions(+), 83 deletions(-) diff --git a/recipe/valen/export.md b/recipe/valen/export.md index 522f320b..839689bd 100644 --- a/recipe/valen/export.md +++ b/recipe/valen/export.md @@ -33,12 +33,13 @@ below the ~4.5 GiB artifact size. Expected console output: ~~~text exported weights/valen-export - backbone: 392 tensors + backbone: 578 tensors decision-head: 2 tensors - lora-merged: 186 tensors + lora: 372 tensors merger: 6 tensors vision-top: 48 tensors downcasts: 36 fp32 -> bf16 + lora: 372 unmerged fp32 keys over 186 targets manifest: weights/valen-export/valen_export.json ~~~ @@ -51,31 +52,33 @@ fails loudly instead of exporting the wrong weights. ## The artifact set -Four files, nothing else: +Five files, nothing else: | File | Contents | | --- | --- | -| `model.safetensors` | The backbone in the base's own key layout (`model.language_model.*` / `model.visual.*`), BF16 only: 632 base tensors with the language LoRA merged (186), the trained vision-top blocks 20–23 and the merger substituted as plain replacements (48 + 6), the remaining 392 passing through (36 of them downcast from FP32). | +| `model.safetensors` | The backbone in the base's own key layout (`model.language_model.*` / `model.visual.*`), BF16 only: the base's 632 tensors with the trained vision-top blocks 20–23 and the merger substituted as plain replacements (48 + 6), the remaining 578 passing through verbatim (36 of them downcast from FP32). The 186 language LoRA targets are base tensors here, untouched. | +| `lora.safetensors` | The language LoRA, served unmerged: 372 FP32 tensors under their PEFT key names, verbatim from the checkpoint. | | `head.safetensors` | The decision head: two FP32 matrices `head.decision.weight` and `head.candidate.weight`, no bias. | | `config.json` | The base config copied verbatim, so the reference builder loads the export unchanged. | | `valen_export.json` | The manifest (below). | -Inventory, totaling 634 manifest tensor records: 392 `backbone`, 186 -`lora-merged`, 48 `vision-top`, 6 `merger`, 2 `decision-head`. The -vision-top blocks and merger land in the base's own `model.visual.*` -layout — same key tails and shapes as the base snapshot — so they are -drop-in replacements. +Inventory, totaling 1006 manifest tensor records: 578 `backbone`, 372 +`lora`, 48 `vision-top`, 6 `merger`, 2 `decision-head`. The vision-top +blocks and merger land in the base's own `model.visual.*` layout — same +key tails and shapes as the base snapshot — so they are drop-in +replacements. ## The manifest -`valen_export.json` is format `valen-checkpoint-export/1` and is the +`valen_export.json` is format `valen-checkpoint-export/2` and is the contract for auditing the export without the reference code. Its top-level structure: ~~~text -format "valen-checkpoint-export/1" +format "valen-checkpoint-export/2" sources pinned checkpoint and base revisions + SHA-256s, tool versions -merge the merge decision, formula, scaling, counts, fallback clause +lora the adapter file, dtype, rank/alpha/scaling, target and key + counts, and the declared application semantics downcasts the names of every FP32 -> BF16 downcast tensor head head file, dtype, projection_dim, hidden_size, formulas tensors per-tensor name/shape/dtype/component/source/file records @@ -87,27 +90,35 @@ parity_slot_shape the documented shape of the parity record `export_checkpoint.validate_export` cross-checks a finished export against its manifest — file digests, tensor inventory against the -safetensors headers, downcast bookkeeping, head config — reading headers -and digests only, so it also runs cheaply against the multi-GiB real -artifact set. - -## The merge decision - -The export merges the language LoRA into the backbone rather than -carrying adapters separately, because the native text path loads merged -weights only. The merge is `bf16(fp32(W) + 2·(B@A))` per targeted weight: -the delta `scaling · (B @ A)` is computed in FP32, added to the -FP32-upcast base weight, and the sum is rounded to BF16 exactly once — -bit-equal to peft 0.18.1's `Linear.merge` at the reference's cast -boundaries. The pinned config's scaling is α/r = 64/32 = 2.0 over 372 -LoRA keys targeting 186 language linears. - -The fallback, recorded in the manifest's merge block, is to export the -FP32 LoRA separately and serve unmerged — the only path that meets the -reference-serving tolerance. It is not implemented; the merged artifact -set keeps exact discrete answers and carries `parity.status = "failed"` -in `valen_export.json` (per-case deltas, device, tolerances and the drift -aggregate are recorded there and in [reference.md](reference.md)). +safetensors headers, downcast bookkeeping, head config, and the adapter +inventory (every `lora.safetensors` key an FP32 tensor that maps onto a +listed backbone tensor, one A and one B per target, at the declared rank) +— reading headers and digests only, so it also runs cheaply against the +multi-GiB real artifact set. + +## The LoRA adapters + +The export serves the language LoRA separately and unmerged: the 372 FP32 +adapter tensors ride in `lora.safetensors` under their PEFT key names +(`backbone.language_model.base_model.model..lora_A.default.weight` +and the `lora_B` twins), verbatim from the checkpoint, and +`model.safetensors` carries the base weights of the 186 targeted linears +untouched. The pinned config is rank 32, alpha 64, scaling α/r = 64/32 = +2.0. + +The application semantics a consumer must implement, recorded verbatim in +the manifest's `lora` block: per targeted linear, +`y = bf16(fp32(bf16(x @ W.t())) + scaling * ((x.float() @ A.t()) @ B.t()))` +— the base GEMM runs in BF16, the LoRA delta is computed in FP32 on the +activations and added to the upcast base output, and the sum is rounded +to BF16 exactly once. These are the unmerged peft 0.18.1 `Linear.forward` +semantics with the adapters kept FP32 at runtime — the same boundaries +the pinned reference serves with, and what +`export_checkpoint.unmerged_lora_output` computes (tested bit-equal +against the installed peft). Merging the adapters into the base weights +instead — `bf16(fp32(W) + 2·(B@A))` — changes the computed outputs +relative to the trained artifact's unmerged semantics, which is why the +export does not do it. ## The FP32 downcasts @@ -139,12 +150,19 @@ full: ~~~text backbone: Qwen3_5ForConditionalGeneration.from_pretrained(out, dtype=bf16, attn_implementation='eager', local_files_only=True).model +lora: wrap backbone.language_model in peft get_peft_model(LoraConfig(r=32, + lora_alpha=64, lora_dropout=0, target_modules=, bias='none')), load lora.safetensors' tensors by key and + keep the adapters fp32 at runtime (the application semantics + recorded in the lora block) head: head.safetensors: DecisionHead(hidden_size=2048, projection_dim=256), fp32, no bias ~~~ The copied `config.json` makes the `from_pretrained` call work against -the export directory directly; the head is rebuilt from its two matrices +the export directory directly; the LoRA is re-applied exactly the way the +pinned reference builds it (the same peft wrap at the recorded rank and +alpha, adapters kept FP32), and the head is rebuilt from its two matrices and recorded dimensions. Nothing else is needed to reconstruct the model. ## Pins and licenses @@ -165,7 +183,7 @@ set inherits those terms. Whether the exported stack reproduces the original checkpoint's answers is the parity gate's question, not this document's claim: the verdict — -numbers, device, tolerances, merge drift — lives in the manifest's +numbers, device, tolerances — lives in the manifest's `parity` slot (left `null` by the export, shaped by `parity_slot_shape`) and in the evidence blocks of [reference.md](reference.md), where the parity run is recorded. Consult @@ -184,7 +202,7 @@ PYTHONPATH=src .venv/bin/python -m pytest tests/valen -q python recipe/valen/test_export_reference.py ~~~ -The first is the CPU-only suite (105 tests passed, 2 artifact-gated skips +The first is the CPU-only suite (101 tests passed, 2 artifact-gated skips at the time of writing). The second chains that suite's export battery plus the env-gated real-artifact determinism gate — set `VALEN_CKPT` and `VALEN_BASE` to run it; it exports twice and compares every byte — and, diff --git a/recipe/valen/reference.md b/recipe/valen/reference.md index a09a44a9..dc46e177 100644 --- a/recipe/valen/reference.md +++ b/recipe/valen/reference.md @@ -471,76 +471,63 @@ records above. The two stacks are loaded serially, each in its own subprocess vision-top blocks, merger and head from `checkpoint.pt`; - exported: the consumption path the export manifest documents — `Qwen3_5ForConditionalGeneration.from_pretrained(weights/valen-export, - dtype=bf16, eager).model` plus the decision head rebuilt from - `head.safetensors` (DecisionHead 2048→256, FP32, no bias), run through the - pinned `Valen` class. + dtype=bf16, eager).model`, the language LoRA re-applied through the same + peft build the pinned reference uses (every language Linear wrapped at the + manifest-declared rank 32 / alpha 64, the 372 FP32 tensors of + `lora.safetensors` loaded by key, adapters kept FP32), plus the decision + head rebuilt from `head.safetensors` (DecisionHead 2048→256, FP32, no + bias), run through the pinned `Valen` class. Both stacks answer the text example and the image fixture compiled by the pinned `Compiler`; the declared gate is exact answer agreement with probability deltas ≤ 1e-6. The block below is the verbatim tool output (device `cuda:0`, NVIDIA GeForce RTX 5060 Laptop GPU, sm_120, 7.96 GiB, -torch 2.11.0+cu128; recorded 2026-10-14): +torch 2.11.0+cu128; recorded 2026-10-15): ~~~text case: text logical tokens: 48 compute tokens: 48 -max probability delta: 7.0580840111e-04 +max probability delta: 0.0000000000e+00 answer agreement: PASS -probability tolerance <= 1e-06: FAIL +probability tolerance <= 1e-06: PASS token accounting: PASS -problem: probability delta 7.058084e-04 exceeds tolerance 1e-06 case: image logical tokens: 108 compute tokens: 108 -max probability delta: 1.7260342836e-02 +max probability delta: 0.0000000000e+00 answer agreement: PASS -probability tolerance <= 1e-06: FAIL +probability tolerance <= 1e-06: PASS token accounting: PASS -problem: probability delta 1.726034e-02 exceeds tolerance 1e-06 -merge drift: 186 adapted modules (probe 256x bf16 rows, seed 0, scaling 2, cpu single thread) - max output delta: 3.125000e-02 (mean 2.024530e-02), worst model.language_model.layers.0.linear_attn.in_proj_a.weight - max relative output drift: 7.575758e-03 (mean 4.521309e-03), worst model.language_model.layers.12.linear_attn.in_proj_b.weight - max ulp ratio: 1, mean bit-exact fraction: 0.530288 +device: cuda:0 (NVIDIA GeForce RTX 5060 Laptop GPU (sm_120, 7.96 GiB)) +parity slot filled: weights/valen-export/valen_export.json +verify_export_parity: ok ~~~ -Per-candidate probabilities (original stack → exported stack): +Per-candidate probabilities (original stack → exported stack, printed by the +tool's own stage runner): ~~~text -text/refund: 0.5159630775 0.4840368927 -> 0.5152572991 0.4847427009 (both choose refund) -image/color: 0.4459854066 0.5540145636 -> 0.4632457486 0.5367543097 (both choose black) +text/refund: 0.5159630775 0.4840368927 -> 0.5159630775 0.4840368927 (both choose refund) +image/color: 0.4459854066 0.5540145636 -> 0.4459854066 0.5540145636 (both choose black) ~~~ -Verdict: **the parity gate fails on the declared 1e-6 tolerance**. The -discrete answers agree exactly on both fixtures and the token accounting is -identical, but the merged export's probabilities drift by 7.1e-4 (text) and -1.7e-2 (image) — the merge drift accumulating through the 24 adapted layers, -three orders of magnitude above the tolerance. The drift measurement itself -is bounded at one BF16 ulp per adapted module (max ulp ratio exactly 1, ~53% -of probe activations bit-exact); the failure is the accumulation, not any -single module. - -Control checks recorded with the run: - -- each stack was loaded twice in separate subprocesses and produced - bit-identical answers, so the cross-stack delta above is fully systematic - (the weight transformation), not run-to-run noise; -- the original stack's probabilities match this recipe's committed serving - records (0.5159631044… / 0.5540145914…) within the 2.7e-8 worker-reference - tolerance recorded above, confirming the comparison's reference side is - the served model; -- every exported tensor was cross-checked bit-exactly against the export - tool's merge math on the given base and checkpoint before the drift run. - -The failed gate triggers the documented fallback: -export the FP32 LoRA separately and serve unmerged (recorded in the -manifest's `merge.fallback`), which is the only path that meets the -reference-serving tolerance. The merged artifact set keeps exact discrete -answers and is recorded with `parity.status = "failed"` in -`weights/valen-export/valen_export.json`, together with the per-case -deltas, device, tolerances and the drift aggregate. +Verdict: **the parity gate passes at bit level**. The unmerged export +answers both fixtures with probabilities identical to the original stack's +(max delta exactly 0.0 on every candidate), the discrete answers and token +accounting agree exactly, and the original side matches the committed +serving records above (0.5159631044… / 0.5540145914…) within the ~3e-8 +worker-vs-reference spread. Both stacks apply the LoRA through +the same peft 0.18.1 unmerged `Linear.forward` with FP32 adapters, and the +exported backbone differs from the reference's `from_pretrained` state only +in tensors that are bit-equal by construction (the trained vision-top and +merger replacements, and the 36 FP32→BF16 downcasts the reference's own +BF16 runtime performs at load), so the two forward graphs are numerically +identical. The verdict — per-case deltas, device, tolerance — is recorded +with `parity.status = "passed"` in +`weights/valen-export/valen_export.json`. Reproduce (the export itself per [`export.md`](export.md) first): diff --git a/src/models/valen/README.md b/src/models/valen/README.md index 016148fb..6df70240 100644 --- a/src/models/valen/README.md +++ b/src/models/valen/README.md @@ -172,8 +172,9 @@ the log (the exception type only). ## Checkpoint export The pinned checkpoint and base also export to a native-loadable -safetensors artifact set — LoRA merged, vision-top and merger replaced, -FP32 decision head — with every transformation recorded in a JSON +safetensors artifact set — the language LoRA served separately as unmerged +FP32 adapters, vision-top and merger replaced, FP32 decision head — with +every transformation recorded in a JSON manifest: see [recipe/valen/export.md](../../../recipe/valen/export.md). Consuming that export does not change this worker. From 5bad3c15c047970f0bf8ecc21bca96ed749601c8 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 22:50:09 +0800 Subject: [PATCH 23/32] valen: pin the exported checkpoint digest and reject dirty parity sources --- recipe/valen/export_checkpoint.py | 34 +++++++++++++---- recipe/valen/verify_export_parity.py | 33 ++++++++++++++++ tests/valen/test_export.py | 57 ++++++++++++++++++++++------ tests/valen/test_export_parity.py | 47 +++++++++++++++++++++++ 4 files changed, 152 insertions(+), 19 deletions(-) diff --git a/recipe/valen/export_checkpoint.py b/recipe/valen/export_checkpoint.py index ddc90cb9..41e73f5e 100644 --- a/recipe/valen/export_checkpoint.py +++ b/recipe/valen/export_checkpoint.py @@ -871,6 +871,19 @@ def sha256_file(path): return digest.hexdigest() +def _pinned_checkpoint_sha256(): + """The worker's unconditional checkpoint pin — the load's trust anchor. + + training_metadata.json ships inside the directory it describes, so it + is a consistency record, not a trust anchor; the exporter therefore + enforces the worker's own pinned digest before the pickle is loaded, + exactly as the worker does.""" + + from models.valen.engine import CHECKPOINT_SHA256 + + return CHECKPOINT_SHA256 + + def checkpoint_revision(checkpoint_dir): """The HF revision the Preview directory was pinned to, if recorded. @@ -907,11 +920,12 @@ def base_weights_file(base_dir): def export(checkpoint_dir, base_dir, out_dir): """Export the pinned checkpoint + base into ``out_dir`` (must not exist). - Enforces the artifact-internal pins before writing anything: the - checkpoint digest against training_metadata.json, the base weights - digest against both valen_manifest.json and the checkpoint's own - base_manifest, and the revision agreement between them. Returns the - validated manifest. + Enforces the pins before writing anything: the checkpoint digest + against the worker's pinned CHECKPOINT_SHA256 (the trust anchor) and + the training pipeline's training_metadata.json record, the base + weights digest against both valen_manifest.json and the checkpoint's + own base_manifest, and the revision agreement between them. Returns + the validated manifest. """ checkpoint_dir = Path(checkpoint_dir) @@ -919,12 +933,18 @@ def export(checkpoint_dir, base_dir, out_dir): checkpoint_path = checkpoint_dir / CHECKPOINT_FILE checkpoint_sha = sha256_file(checkpoint_path) + pinned_sha = _pinned_checkpoint_sha256() + if checkpoint_sha != pinned_sha: + raise ValueError( + f"checkpoint sha256 {checkpoint_sha} != the pinned " + f"Valen-Preview-0923 digest {pinned_sha}" + ) metadata_path = checkpoint_dir / TRAINING_METADATA_FILE if not metadata_path.is_file(): raise ValueError( f"checkpoint directory {checkpoint_dir} is missing " - f"{TRAINING_METADATA_FILE}: the checkpoint digest cannot be pinned " - "before the pickle is unpickled (the worker pins unconditionally)" + f"{TRAINING_METADATA_FILE}: the training pipeline's digest record " + "must also agree before the pickle is loaded" ) declared = json.loads(metadata_path.read_text(encoding="utf-8"))["checkpoint_sha256"] if declared != checkpoint_sha: diff --git a/recipe/valen/verify_export_parity.py b/recipe/valen/verify_export_parity.py index 1fa747d5..aa703f0b 100644 --- a/recipe/valen/verify_export_parity.py +++ b/recipe/valen/verify_export_parity.py @@ -336,6 +336,38 @@ def run_fixtures(model, processor, max_length, media_kwargs): return cases +def _verify_clean_checkout(source): + """Reject a dirty pinned checkout before it can produce evidence. + + Both stacks import the valen package from this tree, so a local + modification would make the two agree while neither ran the pinned + code; a clean tracked tree with no importable strays is therefore part + of what a parity record certifies. + """ + + status = subprocess.run( + ["git", "-C", str(source), "status", "--porcelain", "--untracked-files=all"], + check=True, + capture_output=True, + text=True, + ) + problems = [] + for line in status.stdout.splitlines(): + entry = line[3:] + if line.startswith("??"): + # Untracked non-code cannot change what gets imported; a stray + # .py on the package path can. + if entry.endswith(".py"): + problems.append(f"untracked python file {entry}") + else: + problems.append(f"locally changed {entry}") + if problems: + raise SystemExit( + "--valen-source must be a clean checkout of the pinned revision " + "(commit or restore):\n " + "\n ".join(problems) + ) + + def stage_environment(args): """Verify the pinned source and build the shared compile-side state.""" @@ -344,6 +376,7 @@ def stage_environment(args): source = Path(args.valen_source).resolve() _verify_source_revision(source) + _verify_clean_checkout(source) sys.path.insert(0, str(source)) from models.valen.preprocess import ValenProcessor diff --git a/tests/valen/test_export.py b/tests/valen/test_export.py index bfaddb71..e2343a19 100644 --- a/tests/valen/test_export.py +++ b/tests/valen/test_export.py @@ -897,8 +897,14 @@ def tiny_pin_base_dir(directory): return directory -def tiny_pin_checkpoint(directory, *, base_manifest=None, checkpoint_sha=None): - """Save a miniature loadable checkpoint.pt plus its training_metadata.""" +def tiny_pin_checkpoint( + directory, *, base_manifest=None, checkpoint_sha=None, monkeypatch=None +): + """Save a miniature loadable checkpoint.pt plus its training_metadata. + + With a monkeypatch handle the fixture also points the exporter's + worker-pin seam at the tiny file's real digest, so the synthetic bytes + pass the pinned-digest gate and the remaining pin checks stay testable.""" directory = Path(directory) directory.mkdir(parents=True, exist_ok=True) @@ -926,37 +932,60 @@ def tiny_pin_checkpoint(directory, *, base_manifest=None, checkpoint_sha=None): (directory / tool.TRAINING_METADATA_FILE).write_text( json.dumps({"checkpoint_sha256": checkpoint_sha or real_sha}), encoding="utf-8" ) + if monkeypatch is not None: + monkeypatch.setattr(tool, "_pinned_checkpoint_sha256", lambda: real_sha) return directory, real_sha -def test_export_requires_training_metadata_pin(tmp_path): +def test_export_rejects_checkpoint_below_the_worker_pin(tmp_path): + """The exporter must enforce the worker's pinned digest before the + pickle is ever loaded: training_metadata.json lives beside the file it + describes, so a directory that controls one controls both — only the + hard-coded worker pin is a trust anchor (the P1 the review bot raised).""" + + import pytest + + checkpoint = tmp_path / "ckpt" + checkpoint.mkdir() + (checkpoint / tool.CHECKPOINT_FILE).write_bytes(b"not the pinned checkpoint") + with pytest.raises(ValueError, match="pinned Valen-Preview-0923 digest"): + tool.export(checkpoint, tiny_pin_base_dir(tmp_path / "base"), tmp_path / "out") + + +def test_export_requires_training_metadata_pin(tmp_path, monkeypatch): """A checkpoint directory without training_metadata.json must be rejected: the digest pin has to be verified before the pickle is ever unpickled (the worker precedent: src/models/valen/engine.py pins unconditionally).""" import pytest - checkpoint, _ = tiny_pin_checkpoint(tmp_path / "ckpt") + checkpoint, _ = tiny_pin_checkpoint( + tmp_path / "ckpt", monkeypatch=monkeypatch + ) (checkpoint / tool.TRAINING_METADATA_FILE).unlink() with pytest.raises(ValueError, match="training_metadata.json"): tool.export(checkpoint, tiny_pin_base_dir(tmp_path / "base"), tmp_path / "out") -def test_export_rejects_checkpoint_sha_mismatch(tmp_path): +def test_export_rejects_checkpoint_sha_mismatch(tmp_path, monkeypatch): import pytest - checkpoint, _ = tiny_pin_checkpoint(tmp_path / "ckpt", checkpoint_sha="00" * 32) + checkpoint, _ = tiny_pin_checkpoint( + tmp_path / "ckpt", checkpoint_sha="00" * 32, monkeypatch=monkeypatch + ) with pytest.raises(ValueError, match="training_metadata"): tool.export(checkpoint, tiny_pin_base_dir(tmp_path / "base"), tmp_path / "out") -def test_export_requires_valen_manifest_base_digest(tmp_path): +def test_export_requires_valen_manifest_base_digest(tmp_path, monkeypatch): """valen_manifest.json must declare the base weights digest; a missing declaration is a pin bypass, not a pass.""" import pytest - checkpoint, _ = tiny_pin_checkpoint(tmp_path / "ckpt") + checkpoint, _ = tiny_pin_checkpoint( + tmp_path / "ckpt", monkeypatch=monkeypatch + ) base = tiny_pin_base_dir(tmp_path / "base") manifest = json.loads((base / tool.BASE_MANIFEST_FILE).read_text(encoding="utf-8")) del manifest["weight_sha256"] @@ -965,32 +994,35 @@ def test_export_requires_valen_manifest_base_digest(tmp_path): tool.export(checkpoint, base, tmp_path / "out") -def test_export_requires_checkpoint_base_manifest_digest(tmp_path): +def test_export_requires_checkpoint_base_manifest_digest(tmp_path, monkeypatch): """The checkpoint's own base_manifest must declare the base weights digest too; absence must fail the pin instead of skipping half of it.""" import pytest checkpoint, _ = tiny_pin_checkpoint( - tmp_path / "ckpt", base_manifest={"revision": "badbee"} + tmp_path / "ckpt", + base_manifest={"revision": "badbee"}, + monkeypatch=monkeypatch, ) base = tiny_pin_base_dir(tmp_path / "base") with pytest.raises(ValueError, match="checkpoint base_manifest"): tool.export(checkpoint, base, tmp_path / "out") -def test_export_rejects_declared_base_digest_mismatch(tmp_path): +def test_export_rejects_declared_base_digest_mismatch(tmp_path, monkeypatch): import pytest checkpoint, _ = tiny_pin_checkpoint( tmp_path / "ckpt", base_manifest={"revision": "badbee", "weight_sha256": {"wrong.safetensors": "ff" * 32}}, + monkeypatch=monkeypatch, ) with pytest.raises(ValueError, match="checkpoint base_manifest"): tool.export(checkpoint, tiny_pin_base_dir(tmp_path / "base"), tmp_path / "out") -def test_export_pin_stage_passes_on_consistent_pins(tmp_path): +def test_export_pin_stage_passes_on_consistent_pins(tmp_path, monkeypatch): """Sanity: with every pin present and consistent, the pin stage completes and the run proceeds into the inventory check (the tiny payload cannot satisfy the pinned 428-tensor inventory).""" @@ -1004,6 +1036,7 @@ def test_export_pin_stage_passes_on_consistent_pins(tmp_path): checkpoint, _ = tiny_pin_checkpoint( tmp_path / "ckpt", base_manifest={"revision": "badbee", "weight_sha256": declared}, + monkeypatch=monkeypatch, ) with pytest.raises(ValueError, match="inventory"): tool.export(checkpoint, base, tmp_path / "out") diff --git a/tests/valen/test_export_parity.py b/tests/valen/test_export_parity.py index bc089876..a02f864f 100644 --- a/tests/valen/test_export_parity.py +++ b/tests/valen/test_export_parity.py @@ -284,6 +284,53 @@ def test_verify_sources_rejects_swapped_base_shard_bytes(tmp_path): tool.verify_sources_match_manifest(args, manifest) +# --- the pinned-source checkout must be clean before evidence is produced --- + +def fresh_checkout(path): + """An empty git repository standing in for the pinned source tree.""" + + import subprocess + + path.mkdir(parents=True) + subprocess.run(["git", "init", "-q", str(path)], check=True) + return path + + +def test_clean_checkout_passes(tmp_path): + repo = fresh_checkout(tmp_path / "valen-src") + # Untracked non-code cannot change what gets imported, so it is allowed. + (repo / "notes.txt").write_text("editor droppings\n", encoding="utf-8") + tool._verify_clean_checkout(repo) + + +def test_untracked_python_file_is_rejected(tmp_path): + """A stray .py on the package path can shadow or extend the pinned code + both stacks import, so it must fail the gate before evidence exists.""" + + import pytest + + repo = fresh_checkout(tmp_path / "valen-src") + (repo / "stray.py").write_text("# not pinned\n", encoding="utf-8") + with pytest.raises(SystemExit, match="stray.py"): + tool._verify_clean_checkout(repo) + + +def test_tracked_modification_is_rejected(tmp_path): + """Both stacks import from the same tree, so a local modification would + make the two agree while neither ran the pinned code.""" + + import pytest + import subprocess + + repo = fresh_checkout(tmp_path / "valen-src") + model = repo / "model.py" + model.write_text("original\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", "model.py"], check=True) + model.write_text("locally modified\n", encoding="utf-8") + with pytest.raises(SystemExit, match="model.py"): + tool._verify_clean_checkout(repo) + + # --- the full parity gate against the real pinned artifacts --- # Set VALEN_CKPT, VALEN_BASE and VALEN_SRC (the recipe reference.md artifact # exports) on a CUDA host to run: it exports a fresh artifact set, then runs From b6490aefad7647c2aae8e2ef6ce5bf6a8022e79a Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 22:51:39 +0800 Subject: [PATCH 24/32] valen: gate the reference parity run on a clean pinned checkout --- recipe/valen/compare_reference.py | 34 ++++++++++++++++++++++++++++ recipe/valen/reference.md | 2 ++ recipe/valen/verify_export_parity.py | 34 +--------------------------- tests/valen/test_export_parity.py | 7 +++--- 4 files changed, 41 insertions(+), 36 deletions(-) diff --git a/recipe/valen/compare_reference.py b/recipe/valen/compare_reference.py index 618b8298..dc92ccd7 100644 --- a/recipe/valen/compare_reference.py +++ b/recipe/valen/compare_reference.py @@ -29,6 +29,7 @@ import base64 import hashlib import json +import subprocess import sys from pathlib import Path from tempfile import TemporaryDirectory @@ -353,6 +354,38 @@ def run_reference(model, base_processor, record, media_root, max_length, media_k } +def _verify_clean_checkout(source): + """Reject a dirty pinned checkout before it can produce evidence. + + Every evidence-producing harness in this recipe imports the pinned + valen package from this tree, so a local modification would make the + records certify agreement with code nobody pinned; a clean tracked + tree with no importable strays is part of what a record claims. + """ + + status = subprocess.run( + ["git", "-C", str(source), "status", "--porcelain", "--untracked-files=all"], + check=True, + capture_output=True, + text=True, + ) + problems = [] + for line in status.stdout.splitlines(): + entry = line[3:] + if line.startswith("??"): + # Untracked non-code cannot change what gets imported; a stray + # .py on the package path can. + if entry.endswith(".py"): + problems.append(f"untracked python file {entry}") + else: + problems.append(f"locally changed {entry}") + if problems: + raise SystemExit( + "--valen-source must be a clean checkout of the pinned revision " + "(commit or restore):\n " + "\n ".join(problems) + ) + + def main(argv=None): args = parse_args(argv) sys.path.insert(0, str(HERE.parents[1] / "src")) @@ -362,6 +395,7 @@ def main(argv=None): source = Path(args.valen_source).resolve() _verify_source_revision(source) + _verify_clean_checkout(source) sys.path.insert(0, str(source)) checkpoint = Path(args.checkpoint).resolve() diff --git a/recipe/valen/reference.md b/recipe/valen/reference.md index dc46e177..75d35bb9 100644 --- a/recipe/valen/reference.md +++ b/recipe/valen/reference.md @@ -158,6 +158,8 @@ writes `valen_manifest.json`. Verify the final identity before serving: ~~~sh test "$(git -C "$VALEN_SRC" rev-parse HEAD)" = "$VALEN_SOURCE_REV" +test -z "$(git -C "$VALEN_SRC" status --porcelain --untracked-files=no)" +test -z "$(git -C "$VALEN_SRC" ls-files --others --exclude-standard | grep '\.py$')" test "$(sha256sum "$VALEN_CKPT/checkpoint.pt" | cut -d' ' -f1)" = "$VALEN_CHECKPOINT_SHA" .venv/bin/python - <<'PY' import json, os diff --git a/recipe/valen/verify_export_parity.py b/recipe/valen/verify_export_parity.py index aa703f0b..0080fe94 100644 --- a/recipe/valen/verify_export_parity.py +++ b/recipe/valen/verify_export_parity.py @@ -336,38 +336,6 @@ def run_fixtures(model, processor, max_length, media_kwargs): return cases -def _verify_clean_checkout(source): - """Reject a dirty pinned checkout before it can produce evidence. - - Both stacks import the valen package from this tree, so a local - modification would make the two agree while neither ran the pinned - code; a clean tracked tree with no importable strays is therefore part - of what a parity record certifies. - """ - - status = subprocess.run( - ["git", "-C", str(source), "status", "--porcelain", "--untracked-files=all"], - check=True, - capture_output=True, - text=True, - ) - problems = [] - for line in status.stdout.splitlines(): - entry = line[3:] - if line.startswith("??"): - # Untracked non-code cannot change what gets imported; a stray - # .py on the package path can. - if entry.endswith(".py"): - problems.append(f"untracked python file {entry}") - else: - problems.append(f"locally changed {entry}") - if problems: - raise SystemExit( - "--valen-source must be a clean checkout of the pinned revision " - "(commit or restore):\n " + "\n ".join(problems) - ) - - def stage_environment(args): """Verify the pinned source and build the shared compile-side state.""" @@ -376,7 +344,7 @@ def stage_environment(args): source = Path(args.valen_source).resolve() _verify_source_revision(source) - _verify_clean_checkout(source) + reference._verify_clean_checkout(source) sys.path.insert(0, str(source)) from models.valen.preprocess import ValenProcessor diff --git a/tests/valen/test_export_parity.py b/tests/valen/test_export_parity.py index a02f864f..b6553b59 100644 --- a/tests/valen/test_export_parity.py +++ b/tests/valen/test_export_parity.py @@ -21,6 +21,7 @@ RECIPE_VALEN = HERE.parents[1] / "recipe" / "valen" sys.path.insert(0, str(RECIPE_VALEN)) +import compare_reference as reference # noqa: E402 import verify_export_parity as tool # noqa: E402 @@ -300,7 +301,7 @@ def test_clean_checkout_passes(tmp_path): repo = fresh_checkout(tmp_path / "valen-src") # Untracked non-code cannot change what gets imported, so it is allowed. (repo / "notes.txt").write_text("editor droppings\n", encoding="utf-8") - tool._verify_clean_checkout(repo) + reference._verify_clean_checkout(repo) def test_untracked_python_file_is_rejected(tmp_path): @@ -312,7 +313,7 @@ def test_untracked_python_file_is_rejected(tmp_path): repo = fresh_checkout(tmp_path / "valen-src") (repo / "stray.py").write_text("# not pinned\n", encoding="utf-8") with pytest.raises(SystemExit, match="stray.py"): - tool._verify_clean_checkout(repo) + reference._verify_clean_checkout(repo) def test_tracked_modification_is_rejected(tmp_path): @@ -328,7 +329,7 @@ def test_tracked_modification_is_rejected(tmp_path): subprocess.run(["git", "-C", str(repo), "add", "model.py"], check=True) model.write_text("locally modified\n", encoding="utf-8") with pytest.raises(SystemExit, match="model.py"): - tool._verify_clean_checkout(repo) + reference._verify_clean_checkout(repo) # --- the full parity gate against the real pinned artifacts --- From cb4cf900cafb5ba89c6d1756ca13b10fc51b20c2 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 23:21:44 +0800 Subject: [PATCH 25/32] valen: fix the export battery src path and the score confidence fallback --- recipe/valen/compare_reference.py | 14 +++++++++++++- recipe/valen/test_compare_reference.py | 23 ++++++++++++++++++++++- recipe/valen/test_export_reference.py | 1 + 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/recipe/valen/compare_reference.py b/recipe/valen/compare_reference.py index dc92ccd7..93d1372d 100644 --- a/recipe/valen/compare_reference.py +++ b/recipe/valen/compare_reference.py @@ -177,6 +177,16 @@ def reference_confidence(probabilities): return max(0.0, (max(probabilities) - 1 / count) / (1 - 1 / count)) +def reference_score_confidence(probabilities): + """Mirror the worker's score concentration formula (see postprocess.py).""" + + mode = max(range(len(probabilities)), key=probabilities.__getitem__) + count = len(probabilities) + distance = sum(p * abs(i - mode) for i, p in enumerate(probabilities)) + uniform_mad = sum(abs(i - (count - 1) / 2) for i in range(count)) / count + return max(0.0, 1.0 - distance / uniform_mad) + + def compare_case( name, worker_response, reference, expected_types, tolerance=PROBABILITY_TOLERANCE ): @@ -255,7 +265,9 @@ def compare_case( if ref_answer is not None and "confidence" in ref_answer: ref_confidence = ref_answer["confidence"] else: - ref_confidence = reference_confidence(ref_probs) + # The reference score answer carries no confidence; the worker + # exposes Valen's score concentration formula. + ref_confidence = reference_score_confidence(ref_probs) max_conf_delta = max(max_conf_delta, abs(answer["confidence"] - ref_confidence)) else: decision = ref_keys[max(range(len(ref_probs)), key=ref_probs.__getitem__)] diff --git a/recipe/valen/test_compare_reference.py b/recipe/valen/test_compare_reference.py index e716ab36..23e3aa65 100644 --- a/recipe/valen/test_compare_reference.py +++ b/recipe/valen/test_compare_reference.py @@ -85,7 +85,6 @@ def check(name: str, condition: bool, detail: str = "") -> None: pass else: check(f"rejects {bad!r}", False) - bad = None check("rejects non-PNG/JPEG data URLs", True) # 3. Reference record builders. @@ -435,6 +434,28 @@ def score_answer(score, legend, confidence): "\n".join(lines), ) + # A synthetic score reference without a reference confidence falls back + # to the worker's score concentration formula (0.4 here), not the choice + # formula (0.55) that would fail the confidence tolerance. + synthetic_score = { + "keys": {"grade": ["0", "1", "2"]}, + "probabilities": {"grade": [0.7, 0.2, 0.1]}, + "answers": {"grade": {"type": "score", "score": 0.4, "legend": legend}}, + "logical_tokens": 17, + "compute_tokens": 51, + } + lines, ok = tool.compare_case( + "score", + worker_response({"grade": score_answer(0.4, legend, 0.4)}, compute_tokens=51), + synthetic_score, + {"grade": "score"}, + ) + check( + "synthetic score without confidence uses the score formula", + ok and "confidence tolerance <= 1e-06: PASS" in "\n".join(lines), + "\n".join(lines), + ) + failed = 0 for name, passed, detail in results: print(f" {'ok ' if passed else 'FAIL'} {name}") diff --git a/recipe/valen/test_export_reference.py b/recipe/valen/test_export_reference.py index 51483a45..2604415a 100644 --- a/recipe/valen/test_export_reference.py +++ b/recipe/valen/test_export_reference.py @@ -30,6 +30,7 @@ REPO_ROOT = HERE.parents[1] sys.path.insert(0, str(HERE)) sys.path.insert(0, str(REPO_ROOT / "tests" / "valen")) +sys.path.insert(0, str(REPO_ROOT / "src")) import export_checkpoint as tool # noqa: E402 import test_export as contract # noqa: E402 From 369202d60da14dd061936d03963671f78531a9da Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Thu, 15 Oct 2026 23:21:44 +0800 Subject: [PATCH 26/32] docs(valen): pin the recorded counts and add the export nav entry --- mkdocs.yml | 1 + recipe/valen/export.md | 2 +- recipe/valen/reference.md | 22 +++++++++++----------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 055b7c24..f682a528 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -66,6 +66,7 @@ nav: - Cua-S1 text worker: recipe/cua_s1/text.md - Cua-S1 native text worker: recipe/cua_s1/native.md - Valen reference worker: recipe/valen/reference.md + - Valen checkpoint export: recipe/valen/export.md - Cua-S1 vision CUDA Graph: recipe/cua_s1/native_vision_graph.md - Open-Jev native text worker: recipe/open_jev/native.md - Open-Jev-27B H200 validation: recipe/open_jev/validation.md diff --git a/recipe/valen/export.md b/recipe/valen/export.md index 839689bd..0f88be24 100644 --- a/recipe/valen/export.md +++ b/recipe/valen/export.md @@ -202,7 +202,7 @@ PYTHONPATH=src .venv/bin/python -m pytest tests/valen -q python recipe/valen/test_export_reference.py ~~~ -The first is the CPU-only suite (101 tests passed, 2 artifact-gated skips +The first is the CPU-only suite (105 tests passed, 2 artifact-gated skips at the time of writing). The second chains that suite's export battery plus the env-gated real-artifact determinism gate — set `VALEN_CKPT` and `VALEN_BASE` to run it; it exports twice and compares every byte — and, diff --git a/recipe/valen/reference.md b/recipe/valen/reference.md index 75d35bb9..8dd70c5f 100644 --- a/recipe/valen/reference.md +++ b/recipe/valen/reference.md @@ -275,11 +275,11 @@ in the integration pull request): { "model": "valen-preview-0923", "answers": { - "duplicate": {"type": "noul", "noul": 0.0, "probabilities": {"true": 0.0, "false": 0.0}, "confidence": 0.0}, - "authorized": {"type": "noul", "noul": 0.0, "probabilities": {"true": 0.0, "false": 0.0}, "confidence": 0.0} + "duplicate": {"type": "noul", "noul": "", "probabilities": {"true": "", "false": ""}, "confidence": ""}, + "authorized": {"type": "noul", "noul": "", "probabilities": {"true": "", "false": ""}, "confidence": ""} }, - "usage": {"input_tokens": 0, "output_tokens": 0}, - "internal_usage": {"compute_tokens": 0} + "usage": {"input_tokens": "", "output_tokens": 0}, + "internal_usage": {"compute_tokens": ""} } ~~~ @@ -301,13 +301,13 @@ in the integration pull request): "model": "valen-preview-0923", "answers": {"severity": { "type": "score", - "probabilities": {"0": 0.0, "1": 0.0, "2": 0.0}, - "score": 0.0, - "legend": {"0": "level 0 text", "1": "level 1 text", "2": "level 2 text"}, - "confidence": 0.0 + "probabilities": {"0": "", "1": "", "2": ""}, + "score": "", + "legend": {"0": "", "1": "", "2": ""}, + "confidence": "" }}, - "usage": {"input_tokens": 0, "output_tokens": 0}, - "internal_usage": {"compute_tokens": 0} + "usage": {"input_tokens": "", "output_tokens": 0}, + "internal_usage": {"compute_tokens": ""} } ~~~ @@ -372,7 +372,7 @@ python recipe/valen/test_compare_reference.py Validated results: ~~~text -pytest tests/valen: 47 passed in 1.21s +pytest tests/valen: 47 passed in 1.21s (at 2192c27, before the export battery) compileall: passed cargo fmt --all --check: passed frontend integration: 12 passed; 0 failed From d1236c661bf90dcbdcda3928508e84b424486272 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Sun, 18 Oct 2026 11:18:44 +0800 Subject: [PATCH 27/32] valen: stage the export atomically and gate compare_case on list lengths Review follow-ups on the export recipe, kept minimal: - run_export now stages the artifact set in a sibling directory and moves it into place with one rename, so a hard-killed run can no longer strand partial files under --out (a stale staging directory is cleared by the next run with the same target). - run_export rejects base_config_path=None instead of writing a set its own validate_export refuses (the allowed set includes config.json). - compare_case fails a case whose probability lists do not cover every candidate instead of silently truncating the delta scan. - export.md notes the artifact set deliberately excludes tokenizer and processor files (reconstructing the serving stack reads them from the pinned base) and records the new suite count (108 passed, 2 skipped). --- recipe/valen/export.md | 6 +++- recipe/valen/export_checkpoint.py | 45 +++++++++++++++------------- recipe/valen/verify_export_parity.py | 17 +++++++++-- tests/valen/test_export.py | 38 +++++++++++++++++++++++ tests/valen/test_export_parity.py | 13 ++++++++ 5 files changed, 95 insertions(+), 24 deletions(-) diff --git a/recipe/valen/export.md b/recipe/valen/export.md index 0f88be24..584e29e8 100644 --- a/recipe/valen/export.md +++ b/recipe/valen/export.md @@ -68,6 +68,10 @@ blocks and merger land in the base's own `model.visual.*` layout — same key tails and shapes as the base snapshot — so they are drop-in replacements. +The set deliberately carries no tokenizer or processor files: nothing +trained depends on them, and reconstructing the full serving stack still +reads those from the pinned base snapshot alongside the export. + ## The manifest `valen_export.json` is format `valen-checkpoint-export/2` and is the @@ -202,7 +206,7 @@ PYTHONPATH=src .venv/bin/python -m pytest tests/valen -q python recipe/valen/test_export_reference.py ~~~ -The first is the CPU-only suite (105 tests passed, 2 artifact-gated skips +The first is the CPU-only suite (108 tests passed, 2 artifact-gated skips at the time of writing). The second chains that suite's export battery plus the env-gated real-artifact determinism gate — set `VALEN_CKPT` and `VALEN_BASE` to run it; it exports twice and compares every byte — and, diff --git a/recipe/valen/export_checkpoint.py b/recipe/valen/export_checkpoint.py index 41e73f5e..54e70508 100644 --- a/recipe/valen/export_checkpoint.py +++ b/recipe/valen/export_checkpoint.py @@ -46,7 +46,9 @@ import argparse import hashlib import json +import os import re +import shutil import struct import sys from pathlib import Path @@ -346,41 +348,44 @@ def run_export( ``lora_config``/``head_config``/``expected`` are validated against the tensors before anything is written. Returns the manifest dict. - A failed run leaves no partial output behind: every artifact this attempt - wrote is removed (and a directory it created is removed too), so the - not-empty guard never blocks a retry with the same arguments. + ``base_config_path`` is required: the allowed set includes ``config.json``, + so an export without it is one its own ``validate_export`` rejects. The + set is staged in a sibling directory and moved into place with a single + rename, so ``out_dir`` only ever holds a complete export: a failed run + leaves no partial output, and a run killed mid-write leaves at most the + staging directory behind, which the next run with the same target removes. """ + if base_config_path is None: + raise ValueError( + "base_config_path is required: the artifact set includes config.json" + ) out_dir = Path(out_dir) if out_dir.exists() and any(out_dir.iterdir()): raise ValueError(f"output directory {out_dir} already exists and is not empty") - created = not out_dir.exists() - out_dir.mkdir(parents=True, exist_ok=True) + staging = out_dir.with_name(out_dir.name + ".export-partial") + if staging.is_dir(): + # Remains of a run killed mid-write with the same target directory. + shutil.rmtree(staging) + staging.mkdir(parents=True) try: - return _write_export( + manifest = _write_export( checkpoint_weights, base_file, - out_dir, + staging, sources=sources, lora_config=lora_config, head_config=head_config, expected=expected, base_config_path=base_config_path, ) + if out_dir.exists(): + # Empty (checked above); rename replaces the directory whole. + out_dir.rmdir() + os.replace(staging, out_dir) + return manifest except BaseException: - for name in ( - BACKBONE_FILE, - LORA_FILE, - HEAD_FILE, - MANIFEST_FILE, - "config.json", - ): - (out_dir / name).unlink(missing_ok=True) - if created: - try: - out_dir.rmdir() - except OSError: - pass + shutil.rmtree(staging, ignore_errors=True) raise diff --git a/recipe/valen/verify_export_parity.py b/recipe/valen/verify_export_parity.py index 0080fe94..eb19be2b 100644 --- a/recipe/valen/verify_export_parity.py +++ b/recipe/valen/verify_export_parity.py @@ -69,9 +69,9 @@ def compare_case(case, original, exported, tolerance=PROBABILITY_TOLERANCE): Each side carries ``questions`` (per qid: candidate ``keys`` in order and ``probabilities``), the compiler's ``logical_tokens`` and - ``compute_tokens``. The gate requires the same candidate order, exact - answer (argmax) agreement, equal token accounting and probability deltas - within ``tolerance``. + ``compute_tokens``. The gate requires the same candidate order, one + probability per candidate on both sides, exact answer (argmax) agreement, + equal token accounting and probability deltas within ``tolerance``. """ problems = [] @@ -92,6 +92,17 @@ def compare_case(case, original, exported, tolerance=PROBABILITY_TOLERANCE): ) exact = False continue + if ( + len(record["probabilities"]) != len(keys) + or len(other["probabilities"]) != len(keys) + ): + problems.append( + f"{qid}: {len(record['probabilities'])} original and " + f"{len(other['probabilities'])} exported probabilities " + f"for {len(keys)} candidates" + ) + exact = False + continue for key, p_original, p_exported in zip( keys, record["probabilities"], other["probabilities"] ): diff --git a/tests/valen/test_export.py b/tests/valen/test_export.py index e2343a19..48b19847 100644 --- a/tests/valen/test_export.py +++ b/tests/valen/test_export.py @@ -570,6 +570,7 @@ def exploding_write(path, entries): base_config_path=config_file, ) assert not out.exists() or not any(out.iterdir()) + assert not (tmp_path / "export.export-partial").exists() # The same arguments now succeed into the same directory. monkeypatch.setattr(tool, "write_safetensors", real_write) @@ -604,6 +605,43 @@ def exploding_write(path, entries): base_config_path=config_file, ) assert not any(out.iterdir()) + assert not (tmp_path / "export.export-partial").exists() + + +def test_run_export_requires_base_config_path(tmp_path): + """run_export without the base config would write a set its own + validate_export rejects (the allowed set includes config.json).""" + + import pytest + + base_file, _ = materialize_input_dir(tmp_path / "in") + out = tmp_path / "export" + with pytest.raises(ValueError, match="base_config_path"): + tool.run_export( + tiny_checkpoint_weights(), + base_file, + out, + sources=TINY_SOURCES, + lora_config=TINY_LORA, + head_config=TINY_HEAD, + expected=TINY_INVENTORY, + base_config_path=None, + ) + assert not out.exists() + + +def test_run_export_clears_stale_staging_directory(tmp_path): + """A run killed mid-write (SIGKILL/OOM) can leave the staging directory + behind; out_dir itself is never partial, and the next run with the same + target clears the staging remains instead of failing.""" + + out = tmp_path / "export" + stale = tmp_path / "export.export-partial" + stale.mkdir() + (stale / "model.safetensors").write_bytes(b"partial junk") + run_tiny_export(out, tmp_path / "in-a") + assert not stale.exists() + assert (out / tool.MANIFEST_FILE).is_file() def test_run_export_rejects_unexpected_fp32_base_tensor(tmp_path): diff --git a/tests/valen/test_export_parity.py b/tests/valen/test_export_parity.py index b6553b59..eb9cb5a4 100644 --- a/tests/valen/test_export_parity.py +++ b/tests/valen/test_export_parity.py @@ -81,6 +81,19 @@ def test_compare_case_candidate_order_mismatch_fails(): assert any("candidate order" in problem for problem in verdict["problems"]) +def test_compare_case_probability_length_mismatch_fails(): + """A malformed stage payload with fewer probabilities than candidates + must fail the case, not silently truncate the delta scan.""" + + original = case_side([0.5159631, 0.4840369]) + truncated = case_side([0.5159631, 0.4840369]) + truncated["questions"]["refund"]["probabilities"] = [0.5159631] + verdict = tool.compare_case("text", original, truncated) + assert verdict["passed"] is False + assert verdict["exact_answer_agreement"] is False + assert any("probabilities" in problem for problem in verdict["problems"]) + + def test_compare_case_missing_question_fails(): original = case_side([0.5159631, 0.4840369]) emptied = case_side([0.5159631, 0.4840369]) From 7243b6750062242f7e1b40be76837fe3168e94cd Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Sat, 24 Oct 2026 02:10:53 +0800 Subject: [PATCH 28/32] valen: reject non-finite probabilities in the parity comparison --- recipe/valen/compare_reference.py | 14 +++++++ recipe/valen/test_compare_reference.py | 58 ++++++++++++++++++++++++++ recipe/valen/verify_export_parity.py | 21 +++++++++- tests/valen/test_export_parity.py | 24 +++++++++++ 4 files changed, 115 insertions(+), 2 deletions(-) diff --git a/recipe/valen/compare_reference.py b/recipe/valen/compare_reference.py index 93d1372d..707ddda0 100644 --- a/recipe/valen/compare_reference.py +++ b/recipe/valen/compare_reference.py @@ -29,6 +29,7 @@ import base64 import hashlib import json +import math import subprocess import sys from pathlib import Path @@ -226,6 +227,19 @@ def compare_case( decision_exact = False continue ref_probs = reference["probabilities"][qid] + # A NaN difference keeps the previous finite maximum and a NaN-free + # argmax list silently keeps its first candidate, so a non-finite + # probability on either side fails the case before any comparison. + if any( + not math.isfinite(value) + for value in list(answer["probabilities"].values()) + list(ref_probs) + ): + problems.append( + f"{qid}: non-finite probability (NaN or infinity) on the " + "worker or reference side" + ) + decision_exact = False + continue for key, worker_p, ref_p in zip(ref_keys, answer["probabilities"].values(), ref_probs): max_prob_delta = max(max_prob_delta, abs(worker_p - ref_p)) # The pinned reference decode when the harness ran with the pinned diff --git a/recipe/valen/test_compare_reference.py b/recipe/valen/test_compare_reference.py index 23e3aa65..80c16ebe 100644 --- a/recipe/valen/test_compare_reference.py +++ b/recipe/valen/test_compare_reference.py @@ -259,6 +259,64 @@ def request_body(question): "\n".join(lines), ) + # 6b. Non-finite probabilities fail before any delta or argmax work. + # A NaN difference would keep the previous finite maximum and the argmax + # would silently keep a NaN-free first candidate, so both sides are + # rejected up front instead of compared. + lines, ok = tool.compare_case( + "text", + worker_response( + {"move": choice_answer({"up": float("nan"), "down": 0.4}, "up", 0.5)} + ), + REFERENCE, + {"move": "choice"}, + ) + check( + "worker NaN probability fails", + not ok + and "non-finite probability" in "\n".join(lines) + and "decision agreement: FAIL" in "\n".join(lines), + "\n".join(lines), + ) + + nan_reference = { + "keys": {"move": ["up", "down"]}, + "probabilities": {"move": [float("nan"), 0.75]}, + "logical_tokens": 17, + "compute_tokens": 17, + } + lines, ok = tool.compare_case( + "text", + worker_response({"move": choice_answer({"up": 0.25, "down": 0.75}, "down", 0.5)}), + nan_reference, + {"move": "choice"}, + ) + check( + "reference NaN probability fails", + not ok and "non-finite probability" in "\n".join(lines), + "\n".join(lines), + ) + + inf_reference = { + "keys": {"move": ["up", "down"]}, + "probabilities": {"move": [float("inf"), 0.0]}, + "logical_tokens": 17, + "compute_tokens": 17, + } + lines, ok = tool.compare_case( + "text", + worker_response( + {"move": choice_answer({"up": float("inf"), "down": 0.0}, "up", 0.5)} + ), + inf_reference, + {"move": "choice"}, + ) + check( + "matching infinities on both sides still fail", + not ok and "non-finite probability" in "\n".join(lines), + "\n".join(lines), + ) + # 7. noul answers compare P(true) with tolerance. noul_reference = { "keys": {"ask": ["true", "false"]}, diff --git a/recipe/valen/verify_export_parity.py b/recipe/valen/verify_export_parity.py index eb19be2b..bab947f0 100644 --- a/recipe/valen/verify_export_parity.py +++ b/recipe/valen/verify_export_parity.py @@ -45,6 +45,7 @@ import argparse import json +import math import subprocess import sys from pathlib import Path @@ -103,6 +104,20 @@ def compare_case(case, original, exported, tolerance=PROBABILITY_TOLERANCE): ) exact = False continue + # A NaN difference keeps the previous finite maximum and a NaN-free + # argmax list silently keeps its first candidate, so a non-finite + # probability on either side fails the case before any comparison. + if any( + not math.isfinite(value) + for values in (record["probabilities"], other["probabilities"]) + for value in values + ): + problems.append( + f"{qid}: non-finite probability (NaN or infinity) on the " + "original or exported side" + ) + exact = False + continue for key, p_original, p_exported in zip( keys, record["probabilities"], other["probabilities"] ): @@ -192,7 +207,8 @@ def fill_parity_slot(manifest_path, record, *, force=False): ) manifest["parity"] = record manifest_path.write_text( - json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + json.dumps(manifest, indent=2, sort_keys=True, allow_nan=False) + "\n", + encoding="utf-8", ) return manifest @@ -413,7 +429,8 @@ def run_stage(args): "hardware": hardware, "torch": torch.__version__, "cases": cases, - } + }, + allow_nan=False, ) ) return 0 diff --git a/tests/valen/test_export_parity.py b/tests/valen/test_export_parity.py index eb9cb5a4..f6ff3371 100644 --- a/tests/valen/test_export_parity.py +++ b/tests/valen/test_export_parity.py @@ -94,6 +94,30 @@ def test_compare_case_probability_length_mismatch_fails(): assert any("probabilities" in problem for problem in verdict["problems"]) +def test_compare_case_exported_nan_probability_fails(): + """A NaN difference keeps the previous finite maximum and the argmax + keeps a NaN-free first candidate, so a non-finite probability on either + side must fail the case before any delta or argmax work.""" + + original = case_side([0.6, 0.4]) + poisoned = case_side([float("nan"), 0.4]) + verdict = tool.compare_case("text", original, poisoned) + assert verdict["passed"] is False + assert verdict["exact_answer_agreement"] is False + assert any("non-finite probability" in problem for problem in verdict["problems"]) + + +def test_compare_case_matching_infinities_fail(): + """Identical infinities on both sides would delta to NaN or agree on the + argmax; the case still fails because the probabilities are non-finite.""" + + infinite = case_side([float("inf"), 0.0]) + verdict = tool.compare_case("text", infinite, case_side([float("inf"), 0.0])) + assert verdict["passed"] is False + assert verdict["exact_answer_agreement"] is False + assert any("non-finite probability" in problem for problem in verdict["problems"]) + + def test_compare_case_missing_question_fails(): original = case_side([0.5159631, 0.4840369]) emptied = case_side([0.5159631, 0.4840369]) From 70478332ae6fa0fb87a4b8b589ed433a43bb37fc Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Sat, 24 Oct 2026 13:15:14 +0800 Subject: [PATCH 29/32] valen: reject non-finite scalar answers and confidences in the parity comparison --- recipe/valen/compare_reference.py | 38 +++++++++++++++ recipe/valen/export.md | 2 +- recipe/valen/test_compare_reference.py | 65 ++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/recipe/valen/compare_reference.py b/recipe/valen/compare_reference.py index 707ddda0..ada1aebb 100644 --- a/recipe/valen/compare_reference.py +++ b/recipe/valen/compare_reference.py @@ -188,6 +188,30 @@ def reference_score_confidence(probabilities): return max(0.0, 1.0 - distance / uniform_mad) +def scalar_answer_fields(answer, expected_type, ref_answer): + """Every scalar the per-type branches fold into a running maximum. + + max() keeps its first argument against a NaN difference, so each field + here is swept for finiteness before those branches run. Reference values + derived from the probabilities (the choice-shaped noul decode, the + fallback score) are already covered by the probability gate. + """ + + fields = [answer["confidence"]] + if expected_type == "noul": + fields.append(answer["noul"]) + elif expected_type == "score": + fields.append(answer["score"]) + if ref_answer is not None: + if expected_type == "noul" and "noul" in ref_answer: + fields.append(ref_answer["noul"]) + elif expected_type == "score" and "score" in ref_answer: + fields.append(ref_answer["score"]) + if "confidence" in ref_answer: + fields.append(ref_answer["confidence"]) + return fields + + def compare_case( name, worker_response, reference, expected_types, tolerance=PROBABILITY_TOLERANCE ): @@ -246,6 +270,20 @@ def compare_case( # source; synthetic references may omit it and fall back to the # shared probabilities and formulas. ref_answer = reference.get("answers", {}).get(qid) + # The scalar deltas below fold into running maxima, and max() keeps + # its first argument against a NaN difference, so a non-finite + # scalar field on either side fails the case before any tolerance + # verdict. + if any( + not math.isfinite(value) + for value in scalar_answer_fields(answer, expected_type, ref_answer) + ): + problems.append( + f"{qid}: non-finite scalar answer or confidence (NaN or " + "infinity) on the worker or reference side" + ) + decision_exact = False + continue if expected_type == "noul": scalar_answers = True if ref_answer is not None and "noul" in ref_answer: diff --git a/recipe/valen/export.md b/recipe/valen/export.md index 584e29e8..b0c22455 100644 --- a/recipe/valen/export.md +++ b/recipe/valen/export.md @@ -206,7 +206,7 @@ PYTHONPATH=src .venv/bin/python -m pytest tests/valen -q python recipe/valen/test_export_reference.py ~~~ -The first is the CPU-only suite (108 tests passed, 2 artifact-gated skips +The first is the CPU-only suite (110 tests passed, 2 artifact-gated skips at the time of writing). The second chains that suite's export battery plus the env-gated real-artifact determinism gate — set `VALEN_CKPT` and `VALEN_BASE` to run it; it exports twice and compares every byte — and, diff --git a/recipe/valen/test_compare_reference.py b/recipe/valen/test_compare_reference.py index 80c16ebe..b0d5b752 100644 --- a/recipe/valen/test_compare_reference.py +++ b/recipe/valen/test_compare_reference.py @@ -317,6 +317,26 @@ def request_body(question): "\n".join(lines), ) + # 6c. Non-finite scalar answers and confidences fail the same way: the + # per-type branches fold them into running maxima, and max() keeps its + # first argument against a NaN difference, so a NaN delta would + # otherwise leave the tolerance verdicts passing. + lines, ok = tool.compare_case( + "text", + worker_response( + {"move": choice_answer({"up": 0.25, "down": 0.75}, "down", float("nan"))} + ), + REFERENCE, + {"move": "choice"}, + ) + check( + "worker NaN confidence fails", + not ok + and "non-finite scalar answer or confidence" in "\n".join(lines) + and "decision agreement: FAIL" in "\n".join(lines), + "\n".join(lines), + ) + # 7. noul answers compare P(true) with tolerance. noul_reference = { "keys": {"ask": ["true", "false"]}, @@ -363,6 +383,23 @@ def noul_answer(p_true, confidence): "\n".join(lines), ) + # A NaN P(true) with finite probabilities must fail instead of being + # swallowed by the answer-delta maximum. + nan_noul = { + "type": "noul", + "noul": float("nan"), + "probabilities": {"true": 0.75, "false": 0.25}, + "confidence": 0.5, + } + lines, ok = tool.compare_case( + "noul", worker_response({"ask": nan_noul}), noul_reference, {"ask": "noul"} + ) + check( + "worker NaN noul answer fails", + not ok and "non-finite scalar answer or confidence" in "\n".join(lines), + "\n".join(lines), + ) + # A noul request answered in choice shape must fail even when the # candidate probabilities, decision and tokens all match. drifted = { @@ -514,6 +551,34 @@ def score_answer(score, legend, confidence): "\n".join(lines), ) + # A NaN confidence carried by the reference decode must fail instead of + # being swallowed by the confidence-delta maximum. + nan_conf_reference = { + "keys": {"grade": ["0", "1", "2"]}, + "probabilities": {"grade": [0.7, 0.2, 0.1]}, + "answers": { + "grade": { + "type": "score", + "score": 0.4, + "legend": legend, + "confidence": float("nan"), + } + }, + "logical_tokens": 17, + "compute_tokens": 51, + } + lines, ok = tool.compare_case( + "score", + worker_response({"grade": score_answer(0.4, legend, 0.4)}, compute_tokens=51), + nan_conf_reference, + {"grade": "score"}, + ) + check( + "reference NaN confidence fails", + not ok and "non-finite scalar answer or confidence" in "\n".join(lines), + "\n".join(lines), + ) + failed = 0 for name, passed, detail in results: print(f" {'ok ' if passed else 'FAIL'} {name}") From 40f991c2b848b9c41776fde3f93a304f7d5278dd Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Sun, 25 Oct 2026 01:47:25 +0800 Subject: [PATCH 30/32] valen: reject non-finite probabilities, scalar answers and confidences in the parity comparison Forward-port the finite-value guards from spec/valen-export (7243b675, 70478332) into this prerequisite branch. max() keeps its first argument against a NaN difference and a NaN-free argmax list silently keeps its first candidate, so a non-finite probability, scalar answer or confidence on either the worker or the reference side fails the case before any delta, argmax or tolerance verdict. Regressions cover worker NaN probabilities, reference NaN probabilities, matching infinities, worker NaN confidence, worker NaN noul answers and reference NaN confidence. The export-side harness (verify_export_parity.py) and its tests stay on spec/valen-export, as does the score-confidence formula fallback. --- recipe/valen/compare_reference.py | 52 +++++++++++ recipe/valen/reference.md | 8 +- recipe/valen/test_compare_reference.py | 123 +++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 2 deletions(-) diff --git a/recipe/valen/compare_reference.py b/recipe/valen/compare_reference.py index 618b8298..2021d847 100644 --- a/recipe/valen/compare_reference.py +++ b/recipe/valen/compare_reference.py @@ -29,6 +29,7 @@ import base64 import hashlib import json +import math import sys from pathlib import Path from tempfile import TemporaryDirectory @@ -176,6 +177,30 @@ def reference_confidence(probabilities): return max(0.0, (max(probabilities) - 1 / count) / (1 - 1 / count)) +def scalar_answer_fields(answer, expected_type, ref_answer): + """Every scalar the per-type branches fold into a running maximum. + + max() keeps its first argument against a NaN difference, so each field + here is swept for finiteness before those branches run. Reference values + derived from the probabilities (the choice-shaped noul decode, the + fallback score) are already covered by the probability gate. + """ + + fields = [answer["confidence"]] + if expected_type == "noul": + fields.append(answer["noul"]) + elif expected_type == "score": + fields.append(answer["score"]) + if ref_answer is not None: + if expected_type == "noul" and "noul" in ref_answer: + fields.append(ref_answer["noul"]) + elif expected_type == "score" and "score" in ref_answer: + fields.append(ref_answer["score"]) + if "confidence" in ref_answer: + fields.append(ref_answer["confidence"]) + return fields + + def compare_case( name, worker_response, reference, expected_types, tolerance=PROBABILITY_TOLERANCE ): @@ -215,12 +240,39 @@ def compare_case( decision_exact = False continue ref_probs = reference["probabilities"][qid] + # A NaN difference keeps the previous finite maximum and a NaN-free + # argmax list silently keeps its first candidate, so a non-finite + # probability on either side fails the case before any comparison. + if any( + not math.isfinite(value) + for value in list(answer["probabilities"].values()) + list(ref_probs) + ): + problems.append( + f"{qid}: non-finite probability (NaN or infinity) on the " + "worker or reference side" + ) + decision_exact = False + continue for key, worker_p, ref_p in zip(ref_keys, answer["probabilities"].values(), ref_probs): max_prob_delta = max(max_prob_delta, abs(worker_p - ref_p)) # The pinned reference decode when the harness ran with the pinned # source; synthetic references may omit it and fall back to the # shared probabilities and formulas. ref_answer = reference.get("answers", {}).get(qid) + # The scalar deltas below fold into running maxima, and max() keeps + # its first argument against a NaN difference, so a non-finite + # scalar field on either side fails the case before any tolerance + # verdict. + if any( + not math.isfinite(value) + for value in scalar_answer_fields(answer, expected_type, ref_answer) + ): + problems.append( + f"{qid}: non-finite scalar answer or confidence (NaN or " + "infinity) on the worker or reference side" + ) + decision_exact = False + continue if expected_type == "noul": scalar_answers = True if ref_answer is not None and "noul" in ref_answer: diff --git a/recipe/valen/reference.md b/recipe/valen/reference.md index 6475f1e0..c773c1b6 100644 --- a/recipe/valen/reference.md +++ b/recipe/valen/reference.md @@ -426,8 +426,12 @@ criteria are exact discrete agreement (candidate order, the requested answer type, chosen key, score legend), equal token accounting, and the declared tolerance (default `1e-6`, `--tolerance` to override) on probabilities, confidences and the scalar answer fields (`noul` P(true), `score` expected -level). An answer whose public `type` drifts from the requested question -type fails the run instead of skipping the noul/score checks: +level). A non-finite probability, scalar answer or confidence on either side +fails the run before any tolerance verdict: `max()` keeps its previous finite +maximum against a NaN difference, so a NaN delta would otherwise leave every +tolerance check passing. An answer whose public `type` drifts from the +requested question type fails the run instead of skipping the noul/score +checks: ~~~sh PYTHONPATH=src .venv/bin/python recipe/valen/compare_reference.py \ diff --git a/recipe/valen/test_compare_reference.py b/recipe/valen/test_compare_reference.py index e716ab36..dc449cf9 100644 --- a/recipe/valen/test_compare_reference.py +++ b/recipe/valen/test_compare_reference.py @@ -260,6 +260,84 @@ def request_body(question): "\n".join(lines), ) + # 6b. Non-finite probabilities fail before any delta or argmax work. + # A NaN difference would keep the previous finite maximum and the argmax + # would silently keep a NaN-free first candidate, so both sides are + # rejected up front instead of compared. + lines, ok = tool.compare_case( + "text", + worker_response( + {"move": choice_answer({"up": float("nan"), "down": 0.4}, "up", 0.5)} + ), + REFERENCE, + {"move": "choice"}, + ) + check( + "worker NaN probability fails", + not ok + and "non-finite probability" in "\n".join(lines) + and "decision agreement: FAIL" in "\n".join(lines), + "\n".join(lines), + ) + + nan_reference = { + "keys": {"move": ["up", "down"]}, + "probabilities": {"move": [float("nan"), 0.75]}, + "logical_tokens": 17, + "compute_tokens": 17, + } + lines, ok = tool.compare_case( + "text", + worker_response({"move": choice_answer({"up": 0.25, "down": 0.75}, "down", 0.5)}), + nan_reference, + {"move": "choice"}, + ) + check( + "reference NaN probability fails", + not ok and "non-finite probability" in "\n".join(lines), + "\n".join(lines), + ) + + inf_reference = { + "keys": {"move": ["up", "down"]}, + "probabilities": {"move": [float("inf"), 0.0]}, + "logical_tokens": 17, + "compute_tokens": 17, + } + lines, ok = tool.compare_case( + "text", + worker_response( + {"move": choice_answer({"up": float("inf"), "down": 0.0}, "up", 0.5)} + ), + inf_reference, + {"move": "choice"}, + ) + check( + "matching infinities on both sides still fail", + not ok and "non-finite probability" in "\n".join(lines), + "\n".join(lines), + ) + + # 6c. Non-finite scalar answers and confidences fail the same way: the + # per-type branches fold them into running maxima, and max() keeps its + # first argument against a NaN difference, so a NaN delta would + # otherwise leave the tolerance verdicts passing. + lines, ok = tool.compare_case( + "text", + worker_response( + {"move": choice_answer({"up": 0.25, "down": 0.75}, "down", float("nan"))} + ), + REFERENCE, + {"move": "choice"}, + ) + check( + "worker NaN confidence fails", + not ok + and "non-finite scalar answer or confidence" in "\n".join(lines) + and "decision agreement: FAIL" in "\n".join(lines), + "\n".join(lines), + ) + # 7. noul answers compare P(true) with tolerance. noul_reference = { "keys": {"ask": ["true", "false"]}, @@ -306,6 +384,23 @@ def noul_answer(p_true, confidence): "\n".join(lines), ) + # A NaN P(true) with finite probabilities must fail instead of being + # swallowed by the answer-delta maximum. + nan_noul = { + "type": "noul", + "noul": float("nan"), + "probabilities": {"true": 0.75, "false": 0.25}, + "confidence": 0.5, + } + lines, ok = tool.compare_case( + "noul", worker_response({"ask": nan_noul}), noul_reference, {"ask": "noul"} + ) + check( + "worker NaN noul answer fails", + not ok and "non-finite scalar answer or confidence" in "\n".join(lines), + "\n".join(lines), + ) + # A noul request answered in choice shape must fail even when the # candidate probabilities, decision and tokens all match. drifted = { @@ -435,6 +530,34 @@ def score_answer(score, legend, confidence): "\n".join(lines), ) + # A NaN confidence carried by the reference decode must fail instead of + # being swallowed by the confidence-delta maximum. + nan_conf_reference = { + "keys": {"grade": ["0", "1", "2"]}, + "probabilities": {"grade": [0.7, 0.2, 0.1]}, + "answers": { + "grade": { + "type": "score", + "score": 0.4, + "legend": legend, + "confidence": float("nan"), + } + }, + "logical_tokens": 17, + "compute_tokens": 51, + } + lines, ok = tool.compare_case( + "score", + worker_response({"grade": score_answer(0.4, legend, 0.4)}, compute_tokens=51), + nan_conf_reference, + {"grade": "score"}, + ) + check( + "reference NaN confidence fails", + not ok and "non-finite scalar answer or confidence" in "\n".join(lines), + "\n".join(lines), + ) + failed = 0 for name, passed, detail in results: print(f" {'ok ' if passed else 'FAIL'} {name}") From ab7bf996e9d644db6dc7bea88462f66427cf4d56 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Sun, 25 Oct 2026 01:53:21 +0800 Subject: [PATCH 31/32] valen: reject importable sourceless and extension strays in the clean-source gate _verify_clean_checkout only rejected untracked .py files: a standalone .pyc (or a .so extension module) imports by name exactly like its .py source, and the checked-out directory is prepended to sys.path, so unverified bytecode could shadow a dependency while the run still claimed clean pinned reference code. The sweep now covers every importable suffix from importlib.machinery (source, bytecode, extension) over untracked *and* ignored entries (a checkout-local ignore rule hides from plain status), while __pycache__ caches stay allowed since they are not importable on their own. Both evidence entrypoints share the gate: verify_export_parity.stage_environment calls reference._verify_clean_checkout. Regressions cover the reviewer's sourceless-module repro, an ignored .pyc, an interpreter-tagged extension module, the __pycache__ allowance and the shared-gate pin. reference.md's pre-flight check mirrors the widened rule. --- recipe/valen/compare_reference.py | 49 ++++++++++++++-- recipe/valen/reference.md | 4 +- tests/valen/test_export_parity.py | 98 +++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 7 deletions(-) diff --git a/recipe/valen/compare_reference.py b/recipe/valen/compare_reference.py index ada1aebb..d4cee559 100644 --- a/recipe/valen/compare_reference.py +++ b/recipe/valen/compare_reference.py @@ -32,12 +32,24 @@ import math import subprocess import sys +from importlib.machinery import BYTECODE_SUFFIXES, EXTENSION_SUFFIXES, SOURCE_SUFFIXES from pathlib import Path from tempfile import TemporaryDirectory HERE = Path(__file__).resolve().parent PROBABILITY_TOLERANCE = 1e-6 +# A sourceless bytecode or extension module imports exactly like its .py +# source would, so every importable suffix is swept, not just source files. +IMPORTABLE_SUFFIXES = tuple( + suffix.lower() + for suffix in sorted( + set(SOURCE_SUFFIXES) | set(BYTECODE_SUFFIXES) | set(EXTENSION_SUFFIXES), + key=len, + reverse=True, + ) +) + DEFAULT_TEXT_BODY = { "model": "valen-preview-0923", "state": "The card was charged twice for one order.", @@ -418,6 +430,21 @@ def run_reference(model, base_processor, record, media_root, max_length, media_k } +def _importable_stray(entry): + """Whether an untracked or ignored path could be imported by name. + + Only a module sitting where its .py would be can shadow a dependency: + a sourceless `peft.pyc` or an extension `peft.so` imports just like + `peft.py`. Bytecode under `__pycache__` is the cache of a real import + and is not importable on its own, so re-runs after a first import stay + clean. + """ + + if "__pycache__" in Path(entry).parts: + return False + return entry.lower().endswith(IMPORTABLE_SUFFIXES) + + def _verify_clean_checkout(source): """Reject a dirty pinned checkout before it can produce evidence. @@ -428,7 +455,15 @@ def _verify_clean_checkout(source): """ status = subprocess.run( - ["git", "-C", str(source), "status", "--porcelain", "--untracked-files=all"], + [ + "git", + "-C", + str(source), + "status", + "--porcelain", + "--untracked-files=all", + "--ignored=matching", + ], check=True, capture_output=True, text=True, @@ -436,11 +471,13 @@ def _verify_clean_checkout(source): problems = [] for line in status.stdout.splitlines(): entry = line[3:] - if line.startswith("??"): - # Untracked non-code cannot change what gets imported; a stray - # .py on the package path can. - if entry.endswith(".py"): - problems.append(f"untracked python file {entry}") + if line.startswith(("??", "!!")): + # Untracked or ignored non-code cannot change what gets + # imported; a stray importable module on the package path can, + # and an ignored one hides from plain `status`. + if _importable_stray(entry): + state = "untracked" if line.startswith("??") else "ignored" + problems.append(f"{state} importable file {entry}") else: problems.append(f"locally changed {entry}") if problems: diff --git a/recipe/valen/reference.md b/recipe/valen/reference.md index 387abd87..3337e5b4 100644 --- a/recipe/valen/reference.md +++ b/recipe/valen/reference.md @@ -159,7 +159,9 @@ writes `valen_manifest.json`. Verify the final identity before serving: ~~~sh test "$(git -C "$VALEN_SRC" rev-parse HEAD)" = "$VALEN_SOURCE_REV" test -z "$(git -C "$VALEN_SRC" status --porcelain --untracked-files=no)" -test -z "$(git -C "$VALEN_SRC" ls-files --others --exclude-standard | grep '\.py$')" +# Untracked *and* ignored modules Python can import by name (source, +# sourceless bytecode, extension) must be absent; __pycache__ caches are fine. +test -z "$(git -C "$VALEN_SRC" status --porcelain --untracked-files=all --ignored=matching | grep -E '^(\?\?|!!).*\.(py|pyc|pyd|so)$' | grep -v __pycache__)" test "$(sha256sum "$VALEN_CKPT/checkpoint.pt" | cut -d' ' -f1)" = "$VALEN_CHECKPOINT_SHA" .venv/bin/python - <<'PY' import json, os diff --git a/tests/valen/test_export_parity.py b/tests/valen/test_export_parity.py index f6ff3371..bc85c07b 100644 --- a/tests/valen/test_export_parity.py +++ b/tests/valen/test_export_parity.py @@ -369,6 +369,104 @@ def test_tracked_modification_is_rejected(tmp_path): reference._verify_clean_checkout(repo) +def test_sourceless_bytecode_module_is_rejected(tmp_path): + """A standalone .pyc imports by name exactly like its .py source would, + and the checked-out directory is prepended to sys.path, so a sourceless + module can shadow a dependency while the gate still reports a clean + pinned checkout unless bytecode suffixes are swept too.""" + + import py_compile + + import pytest + + repo = fresh_checkout(tmp_path / "valen-src") + source = repo / "peft.py" + source.write_text( + "REVIEW_MARKER = 'unverified module imported'\n", encoding="utf-8" + ) + py_compile.compile(str(source), cfile=str(repo / "peft.pyc"), doraise=True) + source.unlink() + with pytest.raises(SystemExit, match=r"peft\.pyc"): + reference._verify_clean_checkout(repo) + + +def test_ignored_importable_module_is_rejected(tmp_path): + """A git-ignored module hides from plain `git status`, so the sweep must + include ignored entries or a checkout-local ignore rule reopens the + shadowing path the untracked check closes.""" + + import subprocess + + import pytest + + repo = fresh_checkout(tmp_path / "valen-src") + (repo / ".gitignore").write_text("*.pyc\n", encoding="utf-8") + (repo / "README.md").write_text("fixture\n", encoding="utf-8") + subprocess.run( + ["git", "-C", str(repo), "add", ".gitignore", "README.md"], check=True + ) + subprocess.run( + [ + "git", + "-C", + str(repo), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-qm", + "fixture", + ], + check=True, + ) + (repo / "peft.pyc").write_bytes(b"bytecode placeholder\n") + assert ( + subprocess.run( + ["git", "-C", str(repo), "check-ignore", "-q", "peft.pyc"] + ).returncode + == 0 + ), "fixture setup: peft.pyc must be ignored" + with pytest.raises(SystemExit, match=r"ignored importable file peft\.pyc"): + reference._verify_clean_checkout(repo) + + +def test_extension_module_is_rejected(tmp_path): + """An extension module (`peft.so` and its interpreter-tagged variants) + imports like `peft.py`, so it falls under the same gate.""" + + import pytest + + repo = fresh_checkout(tmp_path / "valen-src") + (repo / "peft.cpython-312-x86_64-linux-gnu.so").write_bytes(b"stub\n") + with pytest.raises(SystemExit, match=r"peft\.cpython-312"): + reference._verify_clean_checkout(repo) + + +def test_bytecode_cache_directory_is_allowed(tmp_path): + """`__pycache__` bytecode is the cache of a real import and is not + importable on its own, so a re-run after a first import must stay + clean instead of failing on its own cache.""" + + repo = fresh_checkout(tmp_path / "valen-src") + cache = repo / "__pycache__" + cache.mkdir() + (cache / "peft.cpython-312.pyc").write_bytes(b"cache placeholder\n") + reference._verify_clean_checkout(repo) + + +def test_export_parity_shares_the_clean_checkout_gate(): + """Both evidence entrypoints must run the same corrected gate: the + export harness stages its environment through the comparison tool's + `_verify_clean_checkout`, not a private copy.""" + + import inspect + + assert "reference._verify_clean_checkout" in inspect.getsource( + tool.stage_environment + ) + + # --- the full parity gate against the real pinned artifacts --- # Set VALEN_CKPT, VALEN_BASE and VALEN_SRC (the recipe reference.md artifact # exports) on a CUDA host to run: it exports a fresh artifact set, then runs From 7a801f01320c43b0938aebb2cf753caab8cea512 Mon Sep 17 00:00:00 2001 From: "Mr.Dong" <1042542469@qq.com> Date: Sun, 25 Oct 2026 01:55:06 +0800 Subject: [PATCH 32/32] valen: move the export reference battery implementation under tests/valen The export regression runner (contract-battery chaining plus the env-gated artifact-set validation) lived entirely in recipe/valen/test_export_reference.py, putting test implementation outside the root tests/ tree. The implementation now lives in tests/valen/export_reference_checks.py and the recipe path is thin entrypoint glue over its main(), so the documented invocation is unchanged. export.md points at the implementation's home and its suite count is refreshed (115 passed, 2 artifact-gated skips). --- recipe/valen/export.md | 10 +- recipe/valen/test_export_reference.py | 147 +---------------------- tests/valen/export_reference_checks.py | 158 +++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 145 deletions(-) create mode 100644 tests/valen/export_reference_checks.py diff --git a/recipe/valen/export.md b/recipe/valen/export.md index b0c22455..0aa4da25 100644 --- a/recipe/valen/export.md +++ b/recipe/valen/export.md @@ -206,10 +206,12 @@ PYTHONPATH=src .venv/bin/python -m pytest tests/valen -q python recipe/valen/test_export_reference.py ~~~ -The first is the CPU-only suite (110 tests passed, 2 artifact-gated skips -at the time of writing). The second chains that suite's export battery plus -the env-gated real-artifact determinism gate — set `VALEN_CKPT` and -`VALEN_BASE` to run it; it exports twice and compares every byte — and, +The first is the CPU-only suite (115 tests passed, 2 artifact-gated skips +at the time of writing). The second — implementation in +[`tests/valen/export_reference_checks.py`](../../tests/valen/export_reference_checks.py), +the recipe path is thin entrypoint glue — chains that suite's export +battery plus the env-gated real-artifact determinism gate — set `VALEN_CKPT` +and `VALEN_BASE` to run it; it exports twice and compares every byte — and, with `VALEN_EXPORT` pointing at an exported directory (defaulting to `weights/valen-export` when present), validates the finished artifact set against its manifest. Without the artifacts, both gated phases print a diff --git a/recipe/valen/test_export_reference.py b/recipe/valen/test_export_reference.py index 2604415a..ed21626a 100644 --- a/recipe/valen/test_export_reference.py +++ b/recipe/valen/test_export_reference.py @@ -1,157 +1,22 @@ #!/usr/bin/env python3 -"""Regression wiring for the Valen checkpoint export. +"""Recipe entrypoint for the Valen export regression battery. -Chains the export contract tests in ``tests/valen/test_export.py`` — the -CPU-only battery plus the env-gated real-artifact determinism gate — and -validates an exported artifact set through the export tool's own -``validate_export``, without duplicating a single assertion: every check -lives in the test module or the tool, and this script only orchestrates. +Thin glue over the implementation in ``tests/valen/export_reference_checks.py`` +(the contract battery plus the env-gated artifact validation); see that +module for what runs and how to enable the artifact-gated phases. python recipe/valen/test_export_reference.py - -Without the pinned artifacts it still runs the CPU battery and prints a -clear skip line for each artifact-gated phase, exiting 0. With -``VALEN_CKPT`` and ``VALEN_BASE`` set (see [reference.md](reference.md), -"Pinned artifacts") the determinism gate runs too, exporting the real -checkpoint twice and comparing every byte. With ``VALEN_EXPORT`` set — or -a ``weights/valen-export`` directory next to the repository root — the -finished artifact set is validated against its manifest. See -[export.md](export.md) for the export itself. """ from __future__ import annotations -import os import sys -import tempfile from pathlib import Path -HERE = Path(__file__).resolve().parent -REPO_ROOT = HERE.parents[1] -sys.path.insert(0, str(HERE)) +REPO_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO_ROOT / "tests" / "valen")) -sys.path.insert(0, str(REPO_ROOT / "src")) - -import export_checkpoint as tool # noqa: E402 -import test_export as contract # noqa: E402 - - -FIXTURE_PARAMS = ("tmp_path", "monkeypatch") - - -def contract_tests(): - """The export contract battery from tests/valen/test_export.py, in module - order; each recognized fixture parameter gets a fresh instance per - test (``tmp_path`` a fresh directory, ``monkeypatch`` a pytest - ``MonkeyPatch`` undone right after).""" - - tests = [] - for name in sorted(dir(contract)): - if not name.startswith("test_"): - continue - function = getattr(contract, name) - params = tuple( - param for param in FIXTURE_PARAMS if param in function.__code__.co_varnames - ) - tests.append((name, function, params)) - return tests - - -def run_contract_battery(results): - """Run every test in tests/valen/test_export.py directly. - - A test that raises pytest's ``Skipped`` outcome (the real-artifact gate - without ``VALEN_CKPT``/``VALEN_BASE``) is reported as a skip with the - test's own reason, never as a failure. - """ - - import pytest - - for name, function, params in contract_tests(): - try: - with tempfile.TemporaryDirectory() as tmp: - monkeypatch = pytest.MonkeyPatch() - kwargs = {} - if "tmp_path" in params: - kwargs["tmp_path"] = Path(tmp) - if "monkeypatch" in params: - kwargs["monkeypatch"] = monkeypatch - try: - function(**kwargs) - finally: - monkeypatch.undo() - except BaseException as error: # noqa: B036 - classified below - if type(error).__name__ == "Skipped": - record(results, name, "skip", str(error)) - else: - record(results, name, False, f"{type(error).__name__}: {error}") - else: - record(results, name, True) - - -def validate_artifact_set(results, export_dir): - """Validate an exported directory through the tool's own cross-check.""" - - try: - manifest = tool.validate_export(Path(export_dir)) - except Exception as error: - record(results, f"validate {export_dir}", False, str(error)) - return - counts = {} - for entry in manifest["tensors"]: - counts[entry["component"]] = counts.get(entry["component"], 0) + 1 - parity = manifest["parity"] - lora = manifest["lora"] - summary = ( - f"{manifest['format']}, {len(manifest['tensors'])} tensors " - + ", ".join(f"{counts[c]} {c}" for c in sorted(counts)) - + f", {len(manifest['downcasts'])} fp32->bf16 downcasts" - + f", lora unmerged r={lora['rank']} alpha={lora['alpha']}" - + f" ({lora['keys']} fp32 keys / {lora['targets']} targets)" - + f", parity slot {'filled' if parity is not None else 'not recorded'}" - ) - record(results, f"validate {export_dir}", True, summary) - - -def record(results, name, outcome, detail=""): - results.append((name, outcome, detail)) - - -def main() -> None: - results: list[tuple[str, object, str]] = [] - - run_contract_battery(results) - - export_dir = os.environ.get("VALEN_EXPORT") - if export_dir: - validate_artifact_set(results, export_dir) - elif (REPO_ROOT / "weights" / "valen-export").is_dir(): - validate_artifact_set(results, REPO_ROOT / "weights" / "valen-export") - else: - record( - results, - "validate exported artifact set", - "skip", - "set VALEN_EXPORT to an exported directory (the export's default " - "output is weights/valen-export) to validate it against its manifest", - ) - failed = skipped = 0 - for name, outcome, detail in results: - if outcome is True: - print(f" ok {name}" + (f" — {detail}" if detail else "")) - elif outcome == "skip": - skipped += 1 - print(f" skip {name} — {detail}") - else: - failed += 1 - print(f" FAIL {name}") - print(" " + detail.strip().replace("\n", "\n ")[:400]) - verdict = "export_reference tests: ok" if not failed else ( - f"export_reference tests: {failed} FAILED" - ) - print(verdict + (f" ({skipped} skipped)" if skipped else "")) - sys.exit(1 if failed else 0) +from export_reference_checks import main # noqa: E402 if __name__ == "__main__": diff --git a/tests/valen/export_reference_checks.py b/tests/valen/export_reference_checks.py new file mode 100644 index 00000000..b97de101 --- /dev/null +++ b/tests/valen/export_reference_checks.py @@ -0,0 +1,158 @@ +"""Regression wiring for the Valen checkpoint export. + +Chains the export contract tests in ``tests/valen/test_export.py`` — the +CPU-only battery plus the env-gated real-artifact determinism gate — and +validates an exported artifact set through the export tool's own +``validate_export``, without duplicating a single assertion: every check +lives in the test module or the tool, and this module only orchestrates. +The recipe entrypoint (``recipe/valen/test_export_reference.py``) is thin +glue over ``main``. + +Importing it runs no tests; the recipe script (or an explicit ``main()``) +drives the battery: + + python recipe/valen/test_export_reference.py + +Without the pinned artifacts it still runs the CPU battery and prints a +clear skip line for each artifact-gated phase, exiting 0. With +``VALEN_CKPT`` and ``VALEN_BASE`` set (see ``recipe/valen/reference.md``, +"Pinned artifacts") the determinism gate runs too, exporting the real +checkpoint twice and comparing every byte. With ``VALEN_EXPORT`` set — or +a ``weights/valen-export`` directory next to the repository root — the +finished artifact set is validated against its manifest. See +``recipe/valen/export.md`` for the export itself. +""" + +from __future__ import annotations + +import os +import sys +import tempfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO_ROOT = HERE.parents[1] +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(REPO_ROOT / "recipe" / "valen")) +sys.path.insert(0, str(REPO_ROOT / "src")) + +import export_checkpoint as tool # noqa: E402 +import test_export as contract # noqa: E402 + + +FIXTURE_PARAMS = ("tmp_path", "monkeypatch") + + +def contract_tests(): + """The export contract battery from tests/valen/test_export.py, in module + order; each recognized fixture parameter gets a fresh instance per + test (``tmp_path`` a fresh directory, ``monkeypatch`` a pytest + ``MonkeyPatch`` undone right after).""" + + tests = [] + for name in sorted(dir(contract)): + if not name.startswith("test_"): + continue + function = getattr(contract, name) + params = tuple( + param for param in FIXTURE_PARAMS if param in function.__code__.co_varnames + ) + tests.append((name, function, params)) + return tests + + +def run_contract_battery(results): + """Run every test in tests/valen/test_export.py directly. + + A test that raises pytest's ``Skipped`` outcome (the real-artifact gate + without ``VALEN_CKPT``/``VALEN_BASE``) is reported as a skip with the + test's own reason, never as a failure. + """ + + import pytest + + for name, function, params in contract_tests(): + try: + with tempfile.TemporaryDirectory() as tmp: + monkeypatch = pytest.MonkeyPatch() + kwargs = {} + if "tmp_path" in params: + kwargs["tmp_path"] = Path(tmp) + if "monkeypatch" in params: + kwargs["monkeypatch"] = monkeypatch + try: + function(**kwargs) + finally: + monkeypatch.undo() + except BaseException as error: # noqa: B036 - classified below + if type(error).__name__ == "Skipped": + record(results, name, "skip", str(error)) + else: + record(results, name, False, f"{type(error).__name__}: {error}") + else: + record(results, name, True) + + +def validate_artifact_set(results, export_dir): + """Validate an exported directory through the tool's own cross-check.""" + + try: + manifest = tool.validate_export(Path(export_dir)) + except Exception as error: + record(results, f"validate {export_dir}", False, str(error)) + return + counts = {} + for entry in manifest["tensors"]: + counts[entry["component"]] = counts.get(entry["component"], 0) + 1 + parity = manifest["parity"] + lora = manifest["lora"] + summary = ( + f"{manifest['format']}, {len(manifest['tensors'])} tensors " + + ", ".join(f"{counts[c]} {c}" for c in sorted(counts)) + + f", {len(manifest['downcasts'])} fp32->bf16 downcasts" + + f", lora unmerged r={lora['rank']} alpha={lora['alpha']}" + + f" ({lora['keys']} fp32 keys / {lora['targets']} targets)" + + f", parity slot {'filled' if parity is not None else 'not recorded'}" + ) + record(results, f"validate {export_dir}", True, summary) + + +def record(results, name, outcome, detail=""): + results.append((name, outcome, detail)) + + +def main() -> None: + results: list[tuple[str, object, str]] = [] + + run_contract_battery(results) + + export_dir = os.environ.get("VALEN_EXPORT") + if export_dir: + validate_artifact_set(results, export_dir) + elif (REPO_ROOT / "weights" / "valen-export").is_dir(): + validate_artifact_set(results, REPO_ROOT / "weights" / "valen-export") + else: + record( + results, + "validate exported artifact set", + "skip", + "set VALEN_EXPORT to an exported directory (the export's default " + "output is weights/valen-export) to validate it against its manifest", + ) + + failed = skipped = 0 + for name, outcome, detail in results: + if outcome is True: + print(f" ok {name}" + (f" — {detail}" if detail else "")) + elif outcome == "skip": + skipped += 1 + print(f" skip {name} — {detail}") + else: + failed += 1 + print(f" FAIL {name}") + print(" " + detail.strip().replace("\n", "\n ")[:400]) + verdict = "export_reference tests: ok" if not failed else ( + f"export_reference tests: {failed} FAILED" + ) + print(verdict + (f" ({skipped} skipped)" if skipped else "")) + sys.exit(1 if failed else 0)