From b0a26663ace37bef1c6f7c1f5be9eca9f4ad013c Mon Sep 17 00:00:00 2001 From: relh Date: Sun, 4 Oct 2026 08:39:11 -1000 Subject: [PATCH] Keep game-owned scoring evidence distinct from sampled tokens Co-authored-by: GPT-6 Codex --- src/bitworld/decision_trajectory.nim | 11 +++++++ tests/test_decision_trajectory.nim | 44 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/bitworld/decision_trajectory.nim b/src/bitworld/decision_trajectory.nim index 918ccb3..8c5329c 100644 --- a/src/bitworld/decision_trajectory.nim +++ b/src/bitworld/decision_trajectory.nim @@ -22,6 +22,7 @@ type model*: Option[string] origin*: AttemptOrigin inferenceMode*: InferenceMode + actionEvidence*: Option[JsonNode] ## Game-owned scoring provenance; never player-supplied. prompt*, request*, response*, rawResponse*, parsedAction*, decoder*: JsonNode accepted*: bool platformCallId*, rejectionReason*, modelIdentity*, tokenizerIdentity*: Option[string] @@ -244,9 +245,19 @@ proc recordDecision*(trajectory: DecisionTrajectory, decisionId, seat: string, selectedPrompt = attempt.prompt if not attempt.accepted or attempt.parsedAction != executedAction: raise newException(ValueError, "selected proposal differs from executed action") + if attempt.actionEvidence.isSome: + let action = attempt.actionEvidence.get() + if action.kind != JObject or action["protocol"].kind != JString or + action["protocol"].getStr().len == 0: + raise newException(ValueError, "game action evidence requires a protocol-tagged object") + if attempt.inferenceMode != imCandidate or attempt.response.kind != JNull or + attempt.sampledTokenIds.isSome or attempt.behaviorLogprobs.isSome: + raise newException(ValueError, "scoring evidence cannot claim generated text or sampled tokens") encoded.add(%*{ "attempt_id": attempt.attemptId, "policy": attempt.policy, "inference_mode": $attempt.inferenceMode, + "action_evidence": (if attempt.actionEvidence.isSome: + copy(attempt.actionEvidence.get()) else: newJNull()), "origin": originName(attempt.origin), "model": jsonOption(attempt.model), "prompt": attempt.prompt, "request": attempt.request, "response": attempt.response, "raw_response": attempt.rawResponse, diff --git a/tests/test_decision_trajectory.nim b/tests/test_decision_trajectory.nim index ce317fc..559dfdb 100644 --- a/tests/test_decision_trajectory.nim +++ b/tests/test_decision_trajectory.nim @@ -285,3 +285,47 @@ suite "private authoritative decision trajectories": check readAttemptEvidence(wire).responseReaderJoined.isNone wire["response_reader_joined"] = %"joined" expect ValueError: discard readAttemptEvidence(wire) + + test "game-owned scoring survives original failure and selected action joins": + let record = episode() + var rejected = newDecisionAttempt("rank-rejected", "policy", aoModel, imCandidate) + rejected.actionEvidence = some(%*{"protocol": "fixture.score.v1", + "scoring_result": {"kind": "rejected", "fault": "provider_error"}}) + rejected.rejectionReason = some("original provider failure") + var selected = newDecisionAttempt("rank-scored", "policy", aoModel, imCandidate) + selected.actionEvidence = some(%*{"protocol": "fixture.score.v1", + "scoring_result": {"kind": "scored", "selected_index": 0}}) + selected.parsedAction = %*{"move": 1} + selected.accepted = true + record.recordDecision("rank", "0", %*{}, @[rejected, selected], + some("rank-scored"), selected.parsedAction, asAccepted) + selected.actionEvidence.get()["scoring_result"]["selected_index"] = %9 + record.finish(esCompleted, %*{}, %*[1]) + let event = parseJson(record.eventsJsonl().splitLines()[0]) + check event["attempts"].len == 2 + check event["attempts"][0]["action_evidence"]["scoring_result"]["kind"].getStr() == "rejected" + check event["attempts"][1]["action_evidence"]["scoring_result"]["selected_index"].getInt() == 0 + check event["attempts"][1]["response"].kind == JNull + check event["executed_action"] == event["attempts"][1]["parsed_action"] + + test "scoring cannot acquire generation or sampled token provenance": + for fault in ["mode", "text", "tokens", "probabilities"]: + var attempt = newDecisionAttempt("rank", "policy", aoModel, imCandidate) + attempt.actionEvidence = some(%*{"protocol": "fixture.score.v1"}) + case fault + of "mode": attempt.inferenceMode = imTextAction + of "text": attempt.response = %"generated-looking text" + of "tokens": attempt.sampledTokenIds = some(@[1]) + else: attempt.behaviorLogprobs = some(@[-0.1]) + expect ValueError: + episode().recordDecision("rank", "0", %*{}, @[attempt], none(string), + %*{"move": 1}, asFallback, fallbackOrigin = some("actual-scripted")) + + test "private players cannot inject game-owned scoring evidence": + var attempt = newDecisionAttempt("rank", "policy", aoModel, imCandidate) + attempt.actionEvidence = some(%*{"protocol": "fixture.score.v1"}) + let wire = attempt.attemptEvidenceJson() + check not wire.hasKey("action_evidence") + wire["action_evidence"] = %*{"protocol": "fixture.score.v1"} + expect ValueError: + discard readAttemptEvidence(wire)