loopmath 0.3.0__py3-none-any.whl → 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. loopmath/__init__.py +1 -1
  2. loopmath/adapters/evolve.py +137 -0
  3. loopmath/adapters/harness2.py +233 -17
  4. loopmath/adapters/openevolve.py +305 -0
  5. loopmath/adapters/plugin_eval.py +585 -0
  6. loopmath/adapters/shinkaevolve.py +358 -0
  7. loopmath/belief/commands.py +17 -1
  8. loopmath/belief/design.py +31 -1
  9. loopmath/belief/fit.py +11 -0
  10. loopmath/belief/integrated.py +139 -0
  11. loopmath/belief/lookahead.py +23 -2
  12. loopmath/belief/sampled.py +416 -0
  13. loopmath/belief/state.py +79 -16
  14. loopmath/cli.py +75 -18
  15. loopmath/cli_errors.py +121 -0
  16. loopmath/cli_registry.py +272 -19
  17. loopmath/compare/__init__.py +188 -50
  18. loopmath/compare/_thresholds_sw.py +439 -0
  19. loopmath/compare/calibrate.py +16 -2
  20. loopmath/compare/calibration.py +16 -3
  21. loopmath/compare/checks.py +144 -16
  22. loopmath/compare/commands.py +97 -27
  23. loopmath/compare/model.py +283 -69
  24. loopmath/compare/units.py +119 -0
  25. loopmath/compare/value.py +564 -0
  26. loopmath/gate/__init__.py +3 -0
  27. loopmath/gate/commands.py +15 -1
  28. loopmath/gate/core.py +58 -15
  29. loopmath/gate/inputs.py +10 -6
  30. loopmath/gate/winner.py +762 -0
  31. loopmath/gate/winner_cli.py +141 -0
  32. loopmath/gate/winner_model.py +305 -0
  33. loopmath/harness_runs/commands.py +180 -10
  34. loopmath/harness_runs/ocp.py +177 -37
  35. loopmath/harness_runs/rows.py +82 -14
  36. loopmath/integrations/__init__.py +5 -0
  37. loopmath/integrations/dspy.py +634 -0
  38. loopmath/output.py +5 -0
  39. loopmath/recommend/commands.py +13 -0
  40. loopmath/recommend_role.py +356 -0
  41. loopmath/screen/__init__.py +13 -0
  42. loopmath/screen/commands.py +416 -0
  43. loopmath/screen/design.py +97 -0
  44. loopmath/screen/edits.py +169 -0
  45. loopmath/screen/model.py +356 -0
  46. loopmath/screen/record.py +250 -0
  47. loopmath/store/budget.py +40 -6
  48. loopmath/store/commands.py +42 -17
  49. loopmath/store/config.py +8 -3
  50. loopmath/store/record.py +6 -2
  51. loopmath/store/status.py +5 -4
  52. loopmath/tasks/__init__.py +13 -0
  53. loopmath/tasks/commands.py +231 -0
  54. loopmath/tasks/data.py +176 -0
  55. loopmath/tasks/fit.py +225 -0
  56. loopmath/tasks/posterior.py +196 -0
  57. loopmath/tasks/select.py +211 -0
  58. loopmath/tasks/sim.py +34 -0
  59. loopmath/types.py +7 -0
  60. loopmath/views/common.py +14 -0
  61. loopmath/views/posterior.py +5 -1
  62. loopmath/views/runs.py +7 -4
  63. {loopmath-0.3.0.dist-info → loopmath-0.3.2.dist-info}/METADATA +43 -15
  64. {loopmath-0.3.0.dist-info → loopmath-0.3.2.dist-info}/RECORD +71 -42
  65. {loopmath-0.3.0.data → loopmath-0.3.2.data}/data/spec/ocp-v0.2.schema.json +0 -0
  66. {loopmath-0.3.0.data → loopmath-0.3.2.data}/data/spec/ocp-v0.schema.json +0 -0
  67. {loopmath-0.3.0.data → loopmath-0.3.2.data}/data/spec/ocp_conformance.py +0 -0
  68. {loopmath-0.3.0.dist-info → loopmath-0.3.2.dist-info}/WHEEL +0 -0
  69. {loopmath-0.3.0.dist-info → loopmath-0.3.2.dist-info}/entry_points.txt +0 -0
  70. {loopmath-0.3.0.dist-info → loopmath-0.3.2.dist-info}/licenses/LICENSE +0 -0
  71. {loopmath-0.3.0.dist-info → loopmath-0.3.2.dist-info}/top_level.txt +0 -0
loopmath/__init__.py CHANGED
@@ -5,4 +5,4 @@ The command line (`loopmath`, or its alias `loop`) is the interface; see the
5
5
  README and `loopmath --help`.
6
6
  """
7
7
 
8
- __version__ = "0.3.0"
8
+ __version__ = "0.3.2"
@@ -0,0 +1,137 @@
1
+ """What the OpenEvolve and ShinkaEvolve readers share: one harness-runs row per LLM call whose model was recorded
2
+ (an OpenEvolve child, a ShinkaEvolve patch query), with one `mutator` stage, and the outcome "this call wrote a child
3
+ that beat its parent".
4
+
5
+ The rows use the run log's `stages` (spec/harness-runs.md): the role is `mutator`, the piece `mutate`, the agent
6
+ the tool's name and the model the one that wrote the child. A verdict outcome says whether the child's score beat
7
+ its parent's by more than `min_delta`; `delta`, `parent_score` and `child_score` ride along as numbers. Nothing
8
+ the tool wrote as text (code, diffs, prompts, replies, feedback, logs) is copied: ext holds ids, numbers and
9
+ loopmath's own labels.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import math
16
+ import re
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
19
+ from typing import Any, Callable
20
+
21
+ ROLE = "mutator"
22
+ PIECE = "mutate"
23
+ EXPERIMENT_LABEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
24
+
25
+
26
+ class FormatError(ValueError):
27
+ """An input the reader does not recognise, named with what it found."""
28
+
29
+
30
+ class NeedsFlag(ValueError):
31
+ """The input is readable, but a flag must say what it does not: the message names the flag."""
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class TablePrice:
36
+ """A price table the caller loaded, as a reader uses it: `usd(model, tokens)` gives the dollars for one
37
+ model's tokens (keys `in`, `cache_read`, `cache_write` and `out`), or None when the table has no row for the
38
+ model; `path` and `as_of` name the table in the notes. The readers never load or price a table themselves."""
39
+
40
+ usd: Callable[[str, dict[str, int]], float | None]
41
+ path: str
42
+ as_of: str
43
+
44
+
45
+ @dataclass
46
+ class Result:
47
+ rows: list[dict[str, Any]]
48
+ format: str
49
+ skipped: list[dict[str, str]] = field(default_factory=list) # [{run_id, reason}]
50
+ counts: dict[str, int] = field(default_factory=dict)
51
+ experiments: list[dict[str, Any]] = field(default_factory=list)
52
+ notes: list[str] = field(default_factory=list)
53
+ accounting: dict[str, float] = field(default_factory=dict) # dollars: total, attributed, unattributed
54
+
55
+
56
+ def number(value: Any) -> float | None:
57
+ """A finite float, or None for anything else (bools are flags, not scores)."""
58
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
59
+ return None
60
+ value = float(value)
61
+ return value if math.isfinite(value) else None
62
+
63
+
64
+ def iso(epoch: Any) -> str | None:
65
+ """Epoch seconds as RFC 3339 UTC, or None."""
66
+ value = number(epoch)
67
+ if value is None or value <= 0:
68
+ return None
69
+ return datetime.fromtimestamp(value, tz=timezone.utc).isoformat().replace("+00:00", "Z")
70
+
71
+
72
+ def experiment_id(prefix: str, root_id: str) -> str:
73
+ """A stable experiment id from the run's first program: `<prefix>-` and 16 hex."""
74
+ return f"{prefix}-" + hashlib.sha256(root_id.encode("utf-8")).hexdigest()[:16]
75
+
76
+
77
+ def check_label(label: str | None) -> None:
78
+ if label is not None and not EXPERIMENT_LABEL.match(label):
79
+ raise NeedsFlag(f"--experiment {label!r}: letters, digits, '.', '_' or '-', at most 64")
80
+
81
+
82
+ def improved(child: float | None, parent: float | None, min_delta: float) -> tuple[bool, float | None]:
83
+ """Whether the child beat its parent by more than `min_delta`, and the delta (None when either is unknown)."""
84
+ if child is None or parent is None:
85
+ return False, None
86
+ delta = child - parent
87
+ return delta > min_delta, delta
88
+
89
+
90
+ def mutator_row(*, tool: str, ns: str, experiment: str, child_id: str, task_id: str, task: dict[str, Any],
91
+ model: str, effort: str | None, accepted: bool, delta: float | None, parent_score: float | None,
92
+ child_score: float | None, metric: str | None, cost: dict[str, Any] | None, status: str,
93
+ started: str | None, ended: str | None, stage_started: str | None, stage_ended: str | None,
94
+ stage: str, note: dict[str, Any]) -> dict[str, Any]:
95
+ """One harness-runs row (spec/harness-runs.md) for one child: a single mutator stage."""
96
+ from ..harness_runs.rows import SCHEMA
97
+
98
+ outcome: dict[str, Any] = {"accepted": accepted, "by": tool}
99
+ if metric:
100
+ outcome["metric"] = metric
101
+ for key, value in (("delta", delta), ("parent_score", parent_score), ("child_score", child_score)):
102
+ if value is not None:
103
+ outcome[key] = value
104
+ st: dict[str, Any] = {"role": ROLE, "piece": PIECE, "status": status}
105
+ if cost:
106
+ st["cost"] = cost
107
+ if stage_started:
108
+ st["started_at"] = stage_started
109
+ if stage_ended:
110
+ st["ended_at"] = stage_ended
111
+ row: dict[str, Any] = {"schema": SCHEMA, "id": f"{tool}:{experiment}:{child_id}", "task_id": task_id,
112
+ "task": task, "experiment": experiment, "status": status, "agent": tool, "model": model,
113
+ "outcome": outcome, "stages": [st],
114
+ "tool": {"name": tool, "run_id": child_id, "task_id": task_id, "stage": stage},
115
+ "ext": {ns: {k: v for k, v in note.items() if v is not None}}}
116
+ if effort:
117
+ row["effort"] = effort
118
+ if started:
119
+ row["started_at"] = started
120
+ if ended:
121
+ row["ended_at"] = ended
122
+ return row
123
+
124
+
125
+ def task_of(task_id: str, task_type: str | None, repo: str | None) -> dict[str, Any]:
126
+ return {"type": task_type or "research", "repo": repo or task_id}
127
+
128
+
129
+ def experiments(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
130
+ counts: dict[str, int] = {}
131
+ for r in rows:
132
+ counts[r["experiment"]] = counts.get(r["experiment"], 0) + 1
133
+ return [{"experiment": e, "runs": n} for e, n in sorted(counts.items())]
134
+
135
+
136
+ def plural(n: int, word: str, many: str | None = None) -> str:
137
+ return f"{n} {word if n == 1 else (many or word + 's')}"
@@ -16,6 +16,7 @@ from __future__ import annotations
16
16
  import csv
17
17
  import hashlib
18
18
  import json
19
+ import math
19
20
  import re
20
21
  from dataclasses import dataclass, field
21
22
  from datetime import datetime, timezone
@@ -31,6 +32,15 @@ MARK = ".adapt_complete" # core/runmark.py:30
31
32
  TOOL = "harness2"
32
33
  TOOL_VERSION = "0.1.0" # pyproject.toml:7
33
34
  NS = "dev.loopmath.adapter.harness2"
35
+ # harness2's own records, copied as it wrote them (the stage, the selection, a crashed mode's record). They hold
36
+ # text a model or harness2 wrote (editor notes, the selector's reason, the lesson, error text), so they sit
37
+ # outside loopmath's namespaces and a run that keeps them is `full`, never `metadata_only` (spec/harness-runs.md
38
+ # section 6, the privacy profile). They are kept only under privacy "full"; by default a row keeps their
39
+ # text-free part under NS (`stage`, `selection`, `crash_kind`) and the summary counts what was withheld.
40
+ RECORDS = "com.github.google-research.harness2"
41
+ PRIVACY = ("metadata_only", "full")
42
+ ERROR_KIND = re.compile(r"^([A-Z][A-Za-z0-9_]*): ")
43
+ TEXT_KEYS = ("error", "errors", "traceback") # summary members holding error text (core/schedule.py:654-659)
34
44
  BENCHES = ("jb", "lab") # <runs>/<bench>/adapt (benchmarks/jobbench.py:512-513, lab.py:484-485)
35
45
  WB_SUBSETS = ("code", "web", "office") # benchmarks/workbuddy.py:84-110; bench wb-<subset> at <runs>/wb/<subset> (653-658)
36
46
  WB_STREAMS = {"oc": "agent/oc-output.txt", "cx": "agent/codex-output.jsonl"} # benchmarks/workbuddy.py:863-864
@@ -42,6 +52,10 @@ LABEL = re.compile(r"^(?P<kind>base|inv|dom|agg|c)(?P<n>\d+)$")
42
52
  EXPERIMENT_LABEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
43
53
  TRIAL = re.compile(r"^(?P<task>.+)__[A-Za-z0-9]+$") # a Harbor trial folder (benchmarks/workbuddy.py:451)
44
54
  TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$")
55
+ # LAB metrics.json numbers and flags kept on the row (run_codex_lab.py:339-353; run_opencode.py:320-331); the
56
+ # token totals are not, since the row's cost is read from the session stream they sum
57
+ METRICS = ("turn_count", "tool_calls", "stream_errors", "wall_clock_seconds", "finished_cleanly", "exit_code",
58
+ "documents_read", "total_documents")
45
59
 
46
60
 
47
61
  class FormatError(ValueError):
@@ -344,6 +358,22 @@ def _runner_record(lay: _Layout, sub: str, out: Path, traj: Path) -> dict[str, A
344
358
  got["effort"] = cfg["reasoning_effort"]
345
359
  if isinstance(cfg.get("started_at"), str) and TIMESTAMP.match(cfg["started_at"]):
346
360
  got["started_at"] = cfg["started_at"]
361
+ note = {key: cfg[key] for key in ("codex_version", "doc_parser") if isinstance(cfg.get(key), str) and cfg[key]}
362
+ if _int(cfg.get("timeout")):
363
+ note["timeout_s"] = cfg["timeout"]
364
+ got["note"] = note
365
+ if lay.bench == "lab" and (out / "metrics.json").is_file(): # run_codex_lab.py:335-359, run_opencode.py:316-336
366
+ _confine(out / "metrics.json", lay.root, "file")
367
+ try:
368
+ met = json.loads((out / "metrics.json").read_text(encoding="utf-8"))
369
+ except (OSError, json.JSONDecodeError):
370
+ met = {}
371
+ if isinstance(met, dict):
372
+ kept = {key: met[key] for key in METRICS if isinstance(met.get(key), (int, float))}
373
+ if kept:
374
+ got.setdefault("note", {})["runner_metrics"] = kept
375
+ if isinstance(met.get("completed_at"), str) and TIMESTAMP.match(met["completed_at"]):
376
+ got["ended_at"] = met["completed_at"]
347
377
  if lay.bench == "jb" and sub == "cx":
348
378
  got["effort"] = "high"
349
379
  index = traj / "attempt_index.tsv"
@@ -364,6 +394,10 @@ def _runner_record(lay: _Layout, sub: str, out: Path, traj: Path) -> dict[str, A
364
394
  return got
365
395
 
366
396
 
397
+ def _int(value: Any) -> bool:
398
+ return isinstance(value, int) and not isinstance(value, bool)
399
+
400
+
367
401
  def _experiment(lay: _Layout, agg_ns: str, config: Any, label: str | None) -> str:
368
402
  """A stable id for one experiment: SHA-256 over the bench, the agg namespace and the canonical
369
403
  `config.json` (executor, improver, judge, tasks, method version, seed; cli/run_sweep.py:209-235), never
@@ -397,9 +431,11 @@ def experiment_id(runs_root: str | Path, agg_namespace: str, label: str | None =
397
431
  def _score(n_passed: Any, n_criteria: Any, raw_score: Any = None) -> float | None:
398
432
  """core/judge.py:32-53: the fraction of criteria passed, when there are criteria. WorkBuddy's judge also
399
433
  gives the verifier's own score, which n_passed rounds to hundredths (benchmarks/workbuddy.py:986-1004);
400
- that score is used when it is a number in [0, 1]."""
401
- if isinstance(n_criteria, int) and not isinstance(n_criteria, bool) and n_criteria > 0 \
402
- and isinstance(n_passed, (int, float)) and not isinstance(n_passed, bool):
434
+ that score is used when it is a number in [0, 1]. JobBench's weighted score gives both as floats (total and
435
+ max score, benchmarks/jobbench.py:598-599, 611-613), so a count need not be an int."""
436
+ if isinstance(n_criteria, (int, float)) and not isinstance(n_criteria, bool) and math.isfinite(n_criteria) \
437
+ and n_criteria > 0 and isinstance(n_passed, (int, float)) and not isinstance(n_passed, bool) \
438
+ and math.isfinite(n_passed):
403
439
  if isinstance(raw_score, (int, float)) and not isinstance(raw_score, bool) and 0 <= raw_score <= 1:
404
440
  return float(raw_score)
405
441
  return float(n_passed) / n_criteria
@@ -407,10 +443,18 @@ def _score(n_passed: Any, n_criteria: Any, raw_score: Any = None) -> float | Non
407
443
 
408
444
 
409
445
  def _outcome(score: float | None, judge: str, raw: dict[str, Any]) -> dict[str, Any]:
446
+ """The judge's score dict as the row's outcome: the members the spec names, and the rest of what harness2's
447
+ score() returned (JobBench's criteria_passed, criteria_count and no_output, benchmarks/jobbench.py:611-619,
448
+ 1060-1068) in `detail`, so nothing it recorded is dropped. `judge_model` is the outcome's judge and WorkBuddy's
449
+ `raw_score` its score (benchmarks/workbuddy.py:999-1004)."""
410
450
  out: dict[str, Any] = {"name": "judge", "score": score, "judge": judge, "scale": "fraction", "better": "higher"}
411
451
  for key in ("n_passed", "n_criteria", "all_pass", "metric"):
412
452
  if raw.get(key) is not None:
413
453
  out[key] = raw[key]
454
+ detail = {key: value for key, value in raw.items()
455
+ if key not in ("n_passed", "n_criteria", "all_pass", "metric", "judge_model", "raw_score")}
456
+ if detail:
457
+ out["detail"] = detail
414
458
  return out
415
459
 
416
460
 
@@ -561,17 +605,132 @@ def _parent_label(mode: str, label: str) -> str | None:
561
605
  return "base" if t == 1 else f"c{t - 1}"
562
606
 
563
607
 
564
- def read(path: str | Path, *, experiment: str | None = None) -> Result:
608
+ def _no_path(sel: dict) -> dict:
609
+ """A selection record as kept on the adopted row: without `chosen_run_dir`, an absolute path on the machine that
610
+ ran it (core/selection.py:48-65). The row's run id names the same folder."""
611
+ return {key: value for key, value in sel.items() if key != "chosen_run_dir"}
612
+
613
+
614
+ def _kind(error: Any) -> str | None:
615
+ """An error's kind without its text: the exception class when it leads as `<Class>: ...` (core/schedule.py:654-657;
616
+ core/recursion.py:172, 296), else `other`; None for no error. The text names files and quotes model output."""
617
+ if not isinstance(error, str) or not error.strip():
618
+ return None
619
+ m = ERROR_KIND.match(error)
620
+ return m[1] if m else "other"
621
+
622
+
623
+ def _meta_stage(rec: dict) -> dict:
624
+ """A stage record's text-free part (core/recursion.py:79-104): its flags, attempts and the kind of its error.
625
+ `notes` and the error text are the editor's and stay out unless --privacy full."""
626
+ out = {key: rec[key] for key in ("dim", "attempts", "cached", "fell_back", "kept_base") if rec.get(key) is not None}
627
+ if _kind(rec.get("error")) is not None:
628
+ out["error_kind"] = _kind(rec.get("error"))
629
+ return out
630
+
631
+
632
+ def _meta_selection(sel: dict) -> dict:
633
+ """A selection record's text-free part (core/selection.py:46-66; core/schedule.py:545-548): ids, flags and
634
+ counts. `why`, `experience`, the `dropped` causes and the error text stay out unless --privacy full."""
635
+ out = {key: sel[key] for key in ("chosen_id", "chosen_label", "chosen_is_base", "chosen_tree_is_base",
636
+ "n_candidates", "ok", "fallback", "trivial", "harness_fingerprint")
637
+ if sel.get(key) is not None}
638
+ if sel.get("evidence") in ("deliverable", "trajectory"): # core/selection.py:65, 151
639
+ out["evidence"] = sel["evidence"]
640
+ if isinstance(sel.get("dropped"), list):
641
+ out["n_dropped"] = len(sel["dropped"])
642
+ out["has_experience"] = bool(sel.get("experience"))
643
+ if _kind(sel.get("error")) is not None:
644
+ out["error_kind"] = _kind(sel.get("error"))
645
+ return out
646
+
647
+
648
+ def _texts(value: Any) -> bool:
649
+ """Whether a value holds any string, the test the privacy profile applies (harness_runs/ocp.py retained_text)."""
650
+ if isinstance(value, str):
651
+ return True
652
+ if isinstance(value, dict):
653
+ return any(_texts(v) for v in value.values())
654
+ if isinstance(value, list):
655
+ return any(_texts(v) for v in value)
656
+ return False
657
+
658
+
659
+ def _recorded_dead(run_dir: Any, task_dir: str) -> bool:
660
+ """A rollout harness2 recorded dead: its run_dir is `<namespace>/<task dir>/missing` (core/schedule.py:424-427,
661
+ 622; core/recursion.py:293). Its error is not written anywhere (no RunView is serialized)."""
662
+ parts = PurePosixPath(str(run_dir or "")).parts
663
+ return len(parts) >= 2 and parts[-2:] == (task_dir, "missing")
664
+
665
+
666
+ def _stage_order(mode: str, label: str) -> tuple:
667
+ """Ledger order (core/recursion.py:366-425, 476-562): parallel inv<i>, dom<i> by i, then agg<i>;
668
+ sequential inv<t>, dom<t>, c<t> by round."""
669
+ m = LABEL.match(label)
670
+ t, kind = int(m["n"]), m["kind"]
671
+ if mode == "agg":
672
+ return (1, t, 0) if kind == "agg" else (0, t, 0 if kind == "inv" else 1)
673
+ return (0, t, ("inv", "dom", "c").index(kind))
674
+
675
+
676
+ def _rolled(harnesses: Path, mode: str) -> list[str]:
677
+ """The stages a mode rolled, from its `harnesses/<label>` folders (each written right before its rollout,
678
+ core/recursion.py:274-276), for a mode whose crash record replaced its ledger (core/schedule.py:655-659)."""
679
+ if not harnesses.is_dir():
680
+ return []
681
+ kinds = ("inv", "dom", "agg") if mode == "agg" else ("inv", "dom", "c")
682
+ labels = [p.name for p in harnesses.iterdir()
683
+ if p.is_dir() and (m := LABEL.match(p.name)) is not None and m["kind"] in kinds]
684
+ return sorted(labels, key=lambda label: _stage_order(mode, label))
685
+
686
+
687
+ def _sweep(lay: _Layout, agg_ns: str) -> dict[str, Any] | None:
688
+ """The newest sweep record of a namespace, `adapt/_sweeps/<namespace>-<UTC stamp>.json`, written once per
689
+ `harness2-run-sweep` after the sweep returns (cli/run_sweep.py:175-178); None when there is none."""
690
+ folder = lay.adapt / "_sweeps"
691
+ stamp = re.compile(re.escape(agg_ns) + r"-\d{8}T\d{6}Z\.json$")
692
+ found = sorted(p for p in folder.glob(f"{agg_ns}-*.json") if stamp.match(p.name)) if folder.is_dir() else []
693
+ if not found:
694
+ return None
695
+ doc = _load(found[-1], lay.root)
696
+ return doc if isinstance(doc, dict) else None
697
+
698
+
699
+ def _sweep_meta(doc: dict[str, Any] | None) -> tuple[dict[str, Any] | None, int]:
700
+ """A sweep record without its error text (core/schedule.py:724-729, 835: a domain's or a task's `error`), and how
701
+ many errors it held."""
702
+ if not isinstance(doc, dict) or not isinstance(doc.get("domains"), list):
703
+ return doc, 0
704
+ held, domains = 0, []
705
+ for dom in doc["domains"]:
706
+ if isinstance(dom, dict):
707
+ held += bool(dom.get("error"))
708
+ dom = {key: value for key, value in dom.items() if key != "error"}
709
+ if isinstance(dom.get("tasks"), list):
710
+ held += sum(1 for t in dom["tasks"] if isinstance(t, dict) and t.get("error"))
711
+ dom["tasks"] = [{key: value for key, value in t.items() if key != "error"} if isinstance(t, dict)
712
+ else t for t in dom["tasks"]]
713
+ domains.append(dom)
714
+ return {**doc, "domains": domains}, held
715
+
716
+
717
+ def read(path: str | Path, *, experiment: str | None = None, privacy: str = "metadata_only") -> Result:
565
718
  """Every rollout under a harness2 runs folder, as checked harness-runs rows. Reads only; writes nothing.
566
719
 
567
720
  Raises FormatError for a folder with no harness2 runs, an unknown method or marker version, or a
568
721
  summary missing its keys, naming what it found. `experiment` labels repeats of one configuration.
722
+ `privacy` "metadata_only" (the default) keeps no text a model or harness2 wrote: harness2's own records
723
+ and any text in a judge's extra members are withheld and counted (`records_withheld`, `text_withheld`).
724
+ "full" keeps them, and the rows that carry them become `full` documents.
569
725
  """
570
726
  from ..harness_runs.rows import SCHEMA, check_row
571
727
  from ..harness_runs.streams import rollout_usage
572
728
 
573
729
  if experiment is not None and not EXPERIMENT_LABEL.match(experiment):
574
730
  raise ValueError(f"--experiment {experiment!r}: letters, digits, '.', '_' or '-', at most 64")
731
+ if privacy not in PRIVACY:
732
+ raise ValueError(f"privacy {privacy!r}: one of {', '.join(PRIVACY)}")
733
+ full = privacy == "full"
575
734
  path = Path(path)
576
735
  layouts = _layouts(path)
577
736
  unsupported = _unsupported(_runs_root(path)) if not layouts or layouts[0].kind == "bench" else []
@@ -586,7 +745,7 @@ def read(path: str | Path, *, experiment: str | None = None) -> Result:
586
745
  counts = {k: 0 for k in ("rollouts", "bases", "stages", "adopted", "scored", "unscored", "failed",
587
746
  "unavailable", "stale_judge", "bare_base", "mode_failed", "kept_base", "fallback",
588
747
  "no_config", "fingerprint_mismatch", "parent_unverified", "unsupported",
589
- "unread_namespace", "cost_unread")}
748
+ "unread_namespace", "cost_unread", "records_withheld", "text_withheld")}
590
749
  for name in unsupported:
591
750
  counts["unsupported"] += 1
592
751
  result.skipped.append({"run_id": f"{name}/adapt", "reason": f"unsupported bench folder: this importer "
@@ -623,8 +782,11 @@ def read(path: str | Path, *, experiment: str | None = None) -> Result:
623
782
  result.notes.append(f"{_rel(ns_dir, root)}: WorkBuddy rollouts carry no cost: the agent stream "
624
783
  f"({WB_STREAMS.get(agg['sub'], 'agent/')}) is written by Harbor and this "
625
784
  f"importer does not read its usage; each row names it as cost_unread")
785
+ tasks: dict[str, Any] = {}
786
+ sweep, held = (_sweep(lay, ns_dir.name), 0) if full else _sweep_meta(_sweep(lay, ns_dir.name))
787
+ counts["text_withheld"] += held
626
788
  result.experiments.append({"experiment": exp, "bench": lay.bench, "namespace": ns_dir.name,
627
- "config": config is not None})
789
+ "config": config, "sweep": sweep, "tasks": tasks})
628
790
  judges = _Judges(ns_dir, root, legacy)
629
791
  agent, kind = AGENTS.get(agg["sub"], (agg["sub"], None))
630
792
  if lay.kind == "demo":
@@ -635,6 +797,15 @@ def read(path: str | Path, *, experiment: str | None = None) -> Result:
635
797
  legacy.extend([True] if summary["_legacy"] else [])
636
798
  task_id = _check_id(str(summary["task_id"]), "task_id", spath, root)
637
799
  task_dir = spath.parent.name # core/schedule.py:101-102
800
+ drop = ("task_id", "base", "selections", "_legacy") + (() if full else TEXT_KEYS)
801
+ tasks[task_id] = {key: value for key, value in summary.items() if key not in drop}
802
+ tasks[task_id]["modes"] = {m: {key: value for key, value in rec.items()
803
+ if key != "stages" and (full or key not in TEXT_KEYS)}
804
+ if isinstance(rec, dict) else rec for m, rec in summary["modes"].items()}
805
+ if not full:
806
+ counts["text_withheld"] += sum(1 for key in TEXT_KEYS if summary.get(key)) + sum(
807
+ 1 for rec in summary["modes"].values() if isinstance(rec, dict)
808
+ for key in TEXT_KEYS if rec.get(key))
638
809
  k = summary["k"] if isinstance(summary["k"], int) else agg["k"]
639
810
  plan: list[dict[str, Any]] = []
640
811
  base_ids = {}
@@ -651,20 +822,33 @@ def read(path: str | Path, *, experiment: str | None = None) -> Result:
651
822
  plan.append({"run_id": rid, "label": label, "mode": "agg", "ns": ns_dir.name, "arm": "base",
652
823
  "tree": ns_dir / task_dir / "harnesses" / "base", "parent": None, "bare":
653
824
  not isinstance(entry, dict),
654
- "run_dir": str(entry.get("run_dir") or "") if isinstance(entry, dict) else ""})
825
+ "run_dir": str(entry.get("run_dir") or "") if isinstance(entry, dict) else "",
826
+ "dead": isinstance(entry, dict) and _recorded_dead(entry.get("run_dir"), task_dir)})
655
827
  for item in plan: # one batch's markers all name its first base (workbuddy.py:853-854)
656
828
  item["mark_ids"] = set(base_ids.values())
657
829
  for mode, rec in sorted(summary["modes"].items()):
658
830
  _check_id(mode, "mode", spath, root, part=True)
659
831
  mode_ns = _mode_ns(agg, mode)
660
832
  mode_dir = lay.adapt / mode_ns / task_dir
833
+ crash = None
661
834
  if not isinstance(rec, dict) or not isinstance(rec.get("ledger"), dict):
662
835
  counts["mode_failed"] += 1 # core/schedule.py:655-659
663
836
  why = (rec or {}).get("error") if isinstance(rec, dict) else None
664
- result.skipped.append({"run_id": f"{mode_ns}/{task_id}",
665
- "reason": f"the {mode} mode failed and left no ledger: {why or 'no record'}"})
666
- continue
667
- stage_usd = {s.get("label"): s.get("usd") for s in rec.get("stages") or [] if isinstance(s, dict)}
837
+ why = why if full or not why else f"{_kind(why)} (its text is kept under --privacy full)"
838
+ rolled = _rolled(mode_dir / "harnesses", mode)
839
+ result.skipped.append({"run_id": f"{mode_ns}/{task_id}", "reason": (
840
+ f"the {mode} mode failed and left no ledger: {why or 'no record'}"
841
+ + (f"; its {len(rolled)} rollouts are read from {_rel(mode_dir / 'harnesses', root)} "
842
+ f"(written before each rollout, core/recursion.py:274-276), each with the crash record"
843
+ if rolled else ""))})
844
+ if not rolled:
845
+ continue
846
+ # the traceback names files on the machine that ran it, so only the rest is kept
847
+ crash = {k: v for k, v in rec.items() if k != "traceback"} if isinstance(rec, dict) \
848
+ else {"error": None}
849
+ rec = {"ledger": {"rollouts": rolled}}
850
+ stage_rec = {s.get("label"): s for s in rec.get("stages") or [] if isinstance(s, dict)}
851
+ stage_usd = {label: s.get("usd") for label, s in stage_rec.items()}
668
852
  sel = summary["selections"].get(mode) or {}
669
853
  if isinstance(sel, dict) and "mode" not in sel and "arm" in sel:
670
854
  legacy.append(True)
@@ -692,7 +876,8 @@ def read(path: str | Path, *, experiment: str | None = None) -> Result:
692
876
  "parent": None if parent is None else mode_dir / "harnesses" / parent,
693
877
  "parent_run_id": prid,
694
878
  "parent_run_dir": recorded if prid is not None and prid == chosen else None,
695
- "editor_usd": stage_usd.get(label),
879
+ "editor_usd": stage_usd.get(label), "stage_record": stage_rec.get(label),
880
+ "selection_record": _no_path(sel) if rid == chosen else None, "mode_crash": crash,
696
881
  "selector_usd": sel.get("usd") if rid == chosen else None,
697
882
  "selection_label": sel.get("chosen_label") if rid == chosen else None,
698
883
  "run_dir": recorded if rid == chosen else None})
@@ -702,7 +887,7 @@ def read(path: str | Path, *, experiment: str | None = None) -> Result:
702
887
  continue
703
888
  seen.add(rid)
704
889
  row = _row(lay, agg, item, task_id, k, exp, experiment, executor, judge_name, agent, kind,
705
- judges, counts, result, rollout_usage)
890
+ judges, counts, result, rollout_usage, full=full)
706
891
  if row is None:
707
892
  continue
708
893
  row["schema"] = SCHEMA
@@ -722,7 +907,7 @@ def read(path: str | Path, *, experiment: str | None = None) -> Result:
722
907
 
723
908
 
724
909
  def _row(lay, agg, item, task_id, k, exp, label, executor, judge_name, agent, kind, judges, counts, result,
725
- rollout_usage) -> dict[str, Any] | None:
910
+ rollout_usage, *, full: bool = False) -> dict[str, Any] | None:
726
911
  rid, is_base = item["run_id"], item["arm"] == "base"
727
912
  tree = item["tree"]
728
913
  if tree.is_dir():
@@ -738,8 +923,14 @@ def _row(lay, agg, item, task_id, k, exp, label, executor, judge_name, agent, ki
738
923
  out, traj = _rollout_dirs(lay, agg["sub"], task_id, rid)
739
924
  if not out.is_dir() and not traj.is_dir():
740
925
  counts["unavailable"] += 1
741
- result.skipped.append({"run_id": rid, "reason": f"unavailable: no run folder at {_rel(out, lay.root)} "
742
- f"(copied without it, or never started)"})
926
+ skip = {"run_id": rid, "reason": (
927
+ f"unavailable: no run folder at {_rel(out, lay.root)} (copied without it, or the rollout died "
928
+ f"before its runner made one); harness2 records no rollout error (it writes no RunView, "
929
+ f"core/recursion.py:79-105)" + ("; harness2 recorded it dead (run_dir <task dir>/missing, "
930
+ "core/schedule.py:424-427)" if item.get("dead") else ""))}
931
+ if isinstance(item.get("stage_record"), dict): # the stage's editor session was paid even so
932
+ skip["stage"] = item["stage_record"] if full else _meta_stage(item["stage_record"])
933
+ result.skipped.append(skip)
743
934
  return None
744
935
  mark = _mark(traj, lay.root) if lay.kind == "bench" else None
745
936
  ok_ids = {rid, *item.get("mark_ids", ())} if _is_wb(lay) and is_base else {rid}
@@ -846,6 +1037,31 @@ def _row(lay, agg, item, task_id, k, exp, label, executor, judge_name, agent, ki
846
1037
  note["parent_changed"] = True
847
1038
  elif unverified and record is not None:
848
1039
  note["parent_unverified"] = unverified
1040
+ if mark is not None: # core/runmark.py:69-75; benchmarks/jobbench.py:755
1041
+ if isinstance(mark.get("at"), (int, float)) and not isinstance(mark.get("at"), bool):
1042
+ note["marked_at"] = _iso(float(mark["at"]))
1043
+ if "output_fingerprint" in mark:
1044
+ note["output_fingerprint"] = mark["output_fingerprint"]
1045
+ note.update(runner.get("note") or {})
1046
+ if item.get("mode_crash") is not None:
1047
+ note["mode_crashed"] = True
1048
+ if _kind(item["mode_crash"].get("error")) is not None:
1049
+ note["crash_kind"] = _kind(item["mode_crash"].get("error"))
1050
+ for key, meta in (("stage", _meta_stage), ("selection", _meta_selection)):
1051
+ if isinstance(item.get(f"{key}_record"), dict):
1052
+ note[key] = meta(item[f"{key}_record"])
1053
+ records = {key: item[f"{key}_record"] for key in ("stage", "selection") if isinstance(item.get(f"{key}_record"), dict)}
1054
+ if item.get("mode_crash") is not None:
1055
+ records["mode_crash"] = item["mode_crash"]
1056
+ if records and not full:
1057
+ counts["records_withheld"] += len(records)
1058
+ records = {}
1059
+ if not full and isinstance(outcome, dict) and isinstance(outcome.get("detail"), dict):
1060
+ texts = [key for key, value in outcome["detail"].items() if _texts(value)]
1061
+ counts["text_withheld"] += len(texts)
1062
+ outcome["detail"] = {key: value for key, value in outcome["detail"].items() if key not in texts}
1063
+ if not outcome["detail"]:
1064
+ del outcome["detail"]
849
1065
  if _is_wb(lay):
850
1066
  counts["cost_unread"] += 1
851
1067
  note["cost_unread"] = (f"{WB_STREAMS.get(agg['sub'], 'the agent stream')} is Harbor's and its usage is not "
@@ -857,7 +1073,7 @@ def _row(lay, agg, item, task_id, k, exp, label, executor, judge_name, agent, ki
857
1073
  "outcome": outcome, "cost": cost,
858
1074
  "tool": {"name": TOOL, "version": TOOL_VERSION, "run_id": rid, "task_id": task_id,
859
1075
  "stage": item["label"]},
860
- "ext": {NS: note}}
1076
+ "ext": {NS: note, **({RECORDS: records} if records else {})}}
861
1077
  if runner.get("effort"):
862
1078
  row["effort"] = runner["effort"]
863
1079
  if started: