Merge nucleic/plucky-north-vole-sdna into dev

This commit is contained in:
2026-08-01 16:10:47 -07:00
parent bb4dff9b56
commit 14e04a4ed5
3 changed files with 82 additions and 7 deletions
+5
View File
@@ -83,6 +83,11 @@ run; both use Luna through subscription-backed `codex exec`, not an API key. The
writes only the first message to canonical source JSONL; state and audit sidecars retain writes only the first message to canonical source JSONL; state and audit sidecars retain
the response solely as a hash and source ID. the response solely as a hash and source ID.
The labeler sends all remaining explanatory response prose to Luna. It replaces fenced
code and structured tool/reasoning payloads with size-marked placeholders, and fails on
oversized remaining prose rather than silently truncating it. The default response limit
is 48,000 characters; raise `--max-response-chars` only after inspecting an outlier.
```bash ```bash
ml/purpose-classifier/.venv/bin/python \ ml/purpose-classifier/.venv/bin/python \
ml/purpose-classifier/label_swe_chat_prompts.py --limit-sessions 1 ml/purpose-classifier/label_swe_chat_prompts.py --limit-sessions 1
+58 -7
View File
@@ -12,6 +12,7 @@ import argparse
import json import json
import math import math
import os import os
import re
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -28,6 +29,11 @@ DEFAULT_INPUT = SCRIPT_DIR / ".artifacts" / "swe-chat" / "candidates.jsonl"
DEFAULT_OUTPUT = SCRIPT_DIR / ".artifacts" / "swe-chat" / "labeled-source.jsonl" DEFAULT_OUTPUT = SCRIPT_DIR / ".artifacts" / "swe-chat" / "labeled-source.jsonl"
MODEL = "gpt-5.6-luna" MODEL = "gpt-5.6-luna"
STATE_SCHEMA_VERSION = 1 STATE_SCHEMA_VERSION = 1
DEFAULT_MAX_RESPONSE_CHARS = 48_000
FENCED_CODE_RE = re.compile(r"(?ms)^[ \t]*```[^\n]*\n.*?^[ \t]*```[ \t]*$")
TOOL_PAYLOAD_RE = re.compile(
r"(?is)<(?:tool_use|tool_result|function_call|function_calls|thinking|analysis)\b[^>]*>.*?</(?:tool_use|tool_result|function_call|function_calls|thinking|analysis)>"
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -114,13 +120,54 @@ def response_schema(batch: Sequence[Candidate]) -> dict[str, Any]:
return schema return schema
def labeling_prompt(batch: Sequence[Candidate], max_chars: int) -> str: def sanitize_agent_response(response: str) -> str:
"""Keep complete explanatory prose while dropping code and tool payloads."""
def replace(kind: str):
def inner(match: re.Match[str]) -> str:
return f"\n\n[{kind} omitted: {len(match.group(0)):,} characters]\n\n"
return inner
sanitized = FENCED_CODE_RE.sub(replace("fenced code"), response)
return TOOL_PAYLOAD_RE.sub(replace("tool or reasoning payload"), sanitized).strip()
def response_for_labeling(response: str, max_chars: int) -> str:
sanitized = sanitize_agent_response(response)
if not sanitized:
return "[agent response contained no prose after sanitization]"
if len(sanitized) > max_chars:
raise DataError(
f"sanitized agent response is {len(sanitized):,} characters, above the "
f"{max_chars:,}-character limit; inspect it or raise --max-response-chars "
"rather than truncating teacher context"
)
return sanitized
def batches(candidates: Sequence[Candidate], *, batch_size: int, batch_chars: int, max_prompt_chars: int, max_response_chars: int) -> list[list[Candidate]]:
result: list[list[Candidate]] = []
current: list[Candidate] = []
current_chars = 0
for candidate in candidates:
size = len(base.excerpt_for_labeling(candidate.prompt, max_prompt_chars)) + len(response_for_labeling(candidate.response, max_response_chars))
if size > batch_chars:
raise DataError(f"{candidate.id}: sanitized prompt/response pair is {size:,} characters, above --batch-chars={batch_chars:,}")
if current and (len(current) >= batch_size or current_chars + size > batch_chars):
result.append(current)
current, current_chars = [], 0
current.append(candidate)
current_chars += size
if current:
result.append(current)
return result
def labeling_prompt(batch: Sequence[Candidate], max_prompt_chars: int, max_response_chars: int) -> str:
payload = { payload = {
"items": [ "items": [
{ {
"id": candidate.id, "id": candidate.id,
"first_message": base.excerpt_for_labeling(candidate.prompt, max_chars), "first_message": base.excerpt_for_labeling(candidate.prompt, max_prompt_chars),
"first_agent_response": base.excerpt_for_labeling(candidate.response, max_chars), "first_agent_response": response_for_labeling(candidate.response, max_response_chars),
} }
for candidate in batch for candidate in batch
] ]
@@ -189,7 +236,7 @@ def validate_decisions(batch: Sequence[Candidate], response: Any) -> list[tuple[
def invoke_codex(args: argparse.Namespace, batch: Sequence[Candidate]) -> list[tuple[Candidate, dict[str, Any]]]: def invoke_codex(args: argparse.Namespace, batch: Sequence[Candidate]) -> list[tuple[Candidate, dict[str, Any]]]:
prompt = labeling_prompt(batch, args.max_prompt_chars) prompt = labeling_prompt(batch, args.max_prompt_chars, args.max_response_chars)
last_error: Exception | None = None last_error: Exception | None = None
for attempt in range(1, args.max_attempts + 1): for attempt in range(1, args.max_attempts + 1):
with tempfile.TemporaryDirectory(prefix="purpose-swe-label-") as directory: with tempfile.TemporaryDirectory(prefix="purpose-swe-label-") as directory:
@@ -278,7 +325,10 @@ def run(args: argparse.Namespace) -> dict[str, int]:
pending = [candidate for candidate in source if candidate.line.number not in states] pending = [candidate for candidate in source if candidate.line.number not in states]
if args.limit_sessions is not None: if args.limit_sessions is not None:
pending = pending[:args.limit_sessions] pending = pending[:args.limit_sessions]
batch_list = list(base.batches(pending, batch_size=args.batch_size, batch_chars=args.batch_chars, max_prompt_chars=args.max_prompt_chars)) batch_list = batches(
pending, batch_size=args.batch_size, batch_chars=args.batch_chars,
max_prompt_chars=args.max_prompt_chars, max_response_chars=args.max_response_chars,
)
print(f"input={len(source)} resumed={len(states)} pending={len(pending)} batches={len(batch_list)} model={MODEL}", flush=True) print(f"input={len(source)} resumed={len(states)} pending={len(pending)} batches={len(batch_list)} model={MODEL}", flush=True)
for number, batch in enumerate(batch_list, 1): for number, batch in enumerate(batch_list, 1):
decisions = invoke_codex(args, batch) # type: ignore[arg-type] decisions = invoke_codex(args, batch) # type: ignore[arg-type]
@@ -314,6 +364,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--batch-size", type=int, default=20) parser.add_argument("--batch-size", type=int, default=20)
parser.add_argument("--batch-chars", type=int, default=80_000) parser.add_argument("--batch-chars", type=int, default=80_000)
parser.add_argument("--max-prompt-chars", type=int, default=24_000) parser.add_argument("--max-prompt-chars", type=int, default=24_000)
parser.add_argument("--max-response-chars", type=int, default=DEFAULT_MAX_RESPONSE_CHARS)
parser.add_argument("--timeout-seconds", type=int, default=600) parser.add_argument("--timeout-seconds", type=int, default=600)
parser.add_argument("--max-attempts", type=int, default=3) parser.add_argument("--max-attempts", type=int, default=3)
parser.add_argument("--limit-sessions", type=int, help="bounded canary/dry-run session count") parser.add_argument("--limit-sessions", type=int, help="bounded canary/dry-run session count")
@@ -327,8 +378,8 @@ def main(argv: Sequence[str] | None = None) -> int:
args.input, args.output = args.input.expanduser().resolve(), args.output.expanduser().resolve() args.input, args.output = args.input.expanduser().resolve(), args.output.expanduser().resolve()
args.state = args.state.expanduser().resolve() if args.state else args.output.with_name(f"{args.output.stem}.state.jsonl") args.state = args.state.expanduser().resolve() if args.state else args.output.with_name(f"{args.output.stem}.state.jsonl")
args.audit = args.audit.expanduser().resolve() if args.audit else args.output.with_name(f"{args.output.stem}.audit.jsonl") args.audit = args.audit.expanduser().resolve() if args.audit else args.output.with_name(f"{args.output.stem}.audit.jsonl")
if args.batch_size <= 0 or args.batch_chars <= 0 or args.max_prompt_chars < 1_000 or args.timeout_seconds <= 0 or args.max_attempts <= 0 or (args.limit_sessions is not None and args.limit_sessions <= 0): if args.batch_size <= 0 or args.batch_chars <= 0 or args.max_prompt_chars < 1_000 or args.max_response_chars < 1_000 or args.timeout_seconds <= 0 or args.max_attempts <= 0 or (args.limit_sessions is not None and args.limit_sessions <= 0):
parser.error("batch sizes, timeout, attempts, and --limit-sessions must be positive; max prompt chars must be at least 1000") parser.error("batch sizes, timeout, attempts, and --limit-sessions must be positive; prompt/response limits must be at least 1000")
try: try:
metrics = run(args) metrics = run(args)
except (DataError, OSError, ValueError, subprocess.SubprocessError) as error: except (DataError, OSError, ValueError, subprocess.SubprocessError) as error:
+19
View File
@@ -64,6 +64,25 @@ class LabelSWEChatPromptsTests(unittest.TestCase):
encoded = json.dumps(state) encoded = json.dumps(state)
self.assertNotIn("It fails only on CI.", encoded) self.assertNotIn("It fails only on CI.", encoded)
def test_response_sanitizer_preserves_all_prose_and_removes_code_payloads(self):
response = (
"I found the likely cause.\n\n"
"```swift\nlet secret = \"not teacher context\"\n```\n\n"
"The fix is to await the task.\n"
"<tool_use>{\"cmd\": \"rm -rf /\"}</tool_use>\n"
"Then rerun the focused test."
)
sanitized = label_swe_chat_prompts.response_for_labeling(response, 2_000)
self.assertIn("I found the likely cause.", sanitized)
self.assertIn("The fix is to await the task.", sanitized)
self.assertIn("Then rerun the focused test.", sanitized)
self.assertNotIn("let secret", sanitized)
self.assertNotIn("rm -rf", sanitized)
def test_response_sanitizer_refuses_to_truncate_prose(self):
with self.assertRaisesRegex(ValueError, "rather than truncating"):
label_swe_chat_prompts.response_for_labeling("x" * 1_001, 1_000)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()