@r1z4x/tezgah 1.6.0 → 1.8.2

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. package/CHANGELOG.md +140 -1
  2. package/MANIFEST +7 -0
  3. package/README.md +6 -3
  4. package/bin/tezgah-decisions +6 -14
  5. package/bin/tezgah-design +21 -5
  6. package/bin/tezgah-docs +18 -16
  7. package/bin/tezgah-doctor +152 -0
  8. package/bin/tezgah-lessons +4 -1
  9. package/bin/tezgah-research +249 -17
  10. package/bin/tezgah-route +70 -8
  11. package/bin/tezgah-setup +26 -13
  12. package/bin/tezgah-task +877 -60
  13. package/bin/tezgah-taste +56 -15
  14. package/docs/architecture.md +28 -11
  15. package/docs/clarity-baseline.json +2 -2
  16. package/docs/contract.md +108 -24
  17. package/docs/environment.md +3 -1
  18. package/docs/evidence.md +209 -28
  19. package/docs/feature-audit.md +1 -1
  20. package/docs/gate.md +78 -23
  21. package/docs/glossary.md +4 -4
  22. package/docs/hosts.md +8 -8
  23. package/docs/index.json +11 -5
  24. package/docs/judge.md +9 -3
  25. package/docs/models.md +75 -14
  26. package/docs/operations.md +133 -3
  27. package/docs/research.md +349 -15
  28. package/docs/skills.md +1 -1
  29. package/docs/status-line.md +5 -5
  30. package/docs/testing.md +20 -13
  31. package/docs/vision.md +12 -4
  32. package/hooks/hooks.json +2 -2
  33. package/hooks/lang/en.json +323 -0
  34. package/hooks/lang/tr.json +344 -0
  35. package/hooks/projects-auto-init.py +29 -1
  36. package/hooks/projects-posttooluse.py +24 -5
  37. package/hooks/projects-pretooluse.py +9 -4
  38. package/hooks/projects-stop.py +28 -16
  39. package/hooks/tezgah_agents.py +53 -17
  40. package/hooks/tezgah_clarity.py +7 -3
  41. package/hooks/tezgah_context.py +489 -438
  42. package/hooks/tezgah_gate.py +996 -80
  43. package/hooks/tezgah_integrity.py +1547 -197
  44. package/hooks/tezgah_models.py +427 -45
  45. package/hooks/tezgah_paths.py +147 -16
  46. package/hooks/tezgah_policy.py +261 -129
  47. package/hooks/tezgah_rank.py +29 -27
  48. package/hooks/tezgah_replay.py +200 -30
  49. package/hooks/tezgah_research.py +1303 -14
  50. package/hooks/tezgah_stopspec.py +7 -0
  51. package/hooks/tezgah_store.py +74 -27
  52. package/hooks/tezgah_task.py +1201 -40
  53. package/hooks/tezgah_taste.py +8 -4
  54. package/hosts/codex/hook.py +32 -13
  55. package/hosts/cursor/hook.py +32 -14
  56. package/hosts/dsh/hooks.json +2 -2
  57. package/hosts/omp/hook.py +44 -23
  58. package/hosts/omp/tezgah-hook.ts.in +93 -7
  59. package/hosts/opencode/plugins/tezgah.js +273 -6
  60. package/output-styles/tezgah.md +76 -42
  61. package/package.json +1 -1
  62. package/skills/i-have-adhd/EVALS.md +22 -2
  63. package/skills/i-have-adhd/SKILL.md +12 -5
  64. package/skills/plan-add/SKILL.md +27 -11
  65. package/skills/plan-add/render_table.py +8 -3
  66. package/skills/plan-sync/SKILL.md +5 -3
  67. package/skills/ponytail/EVALS.md +21 -2
  68. package/skills/ponytail/SKILL.md +4 -4
  69. package/skills/research/SKILL.md +89 -1
  70. package/skills/tezgah-contract/EVALS.md +52 -7
  71. package/skills/tezgah-contract/SKILL.md +154 -59
@@ -85,6 +85,7 @@ here nor appended beside as one.
85
85
  """
86
86
  import fnmatch
87
87
  import json
88
+ import math
88
89
  import os
89
90
  import re
90
91
  import subprocess
@@ -138,18 +139,26 @@ SOURCE_CLASSES = ("formal", "grey", "agent-report")
138
139
  # older set: 2 the standards rules, 3 the ask contract (keyed on the `ask` field),
139
140
  # 4 the protocol sections (`_check_protocol_sections`), 5 the scope lock
140
141
  # (`scope_problems`, `trace_problem`, the method-gated evaluation and the analysis
141
- # that owes no variants). A `rules` above `RULES` is
142
+ # that owes no variants), 6 the selection warnings (`_check_selection`: the
143
+ # held-out set, repeats per arm, the noise band), 7 the exploratory method (an
144
+ # arena locked before the first idea, the idea/stage/tournament rows and their
145
+ # rules, `_check_exploratory`). A `rules` above `RULES` is
142
146
  # a line a later tezgah wrote, and every reader and writer here refuses it by name
143
147
  # (`newer_rules`) rather than judging it by rules it does not know.
144
148
  STANDARDS_RULES = 2
145
149
  PROTOCOL_RULES = 4
146
150
  SCOPE_RULES = 5
147
- RULES = SCOPE_RULES
151
+ HOLDOUT_RULES = 6
152
+ EXPLORE_RULES = 7
153
+ RULES = EXPLORE_RULES
148
154
  # How a rules-5 line answers its question. `qualitative` is a source or
149
155
  # reference review and owes no metric; `measured` runs experiments and locks
150
156
  # one. The question picks it, not the tooling: a qualitative ask answered with a
151
- # metric table is the metric-first drift this field exists against.
152
- METHODS = ("qualitative", "measured")
157
+ # metric table is the metric-first drift this field exists against. `exploratory`
158
+ # (rules 7 only) is a campaign that generates and stages ideas over a locked arena
159
+ # instead of answering one question: the ask is for new ideas, methods or
160
+ # approaches, not for an answer.
161
+ METHODS = ("qualitative", "measured", "exploratory")
153
162
 
154
163
  # What a line delivers, and how many compared variants that takes. `finding` is a
155
164
  # line whose deliverable is its claims; the other four are an artifact a reader
@@ -1021,7 +1030,8 @@ def _check_evaluation(state, errors, warnings, strict):
1021
1030
  is written where a session reads it (`skills/research/SKILL.md`,
1022
1031
  `docs/research.md`); what it cannot catch is a tolerance its own author sets
1023
1032
  so wide that every candidate passes it."""
1024
- if _qualitative(state):
1033
+ if _qualitative(state) or _exploratory(state):
1034
+ # an exploratory line's lock is its arena (`arena_problems`), not one metric
1025
1035
  return
1026
1036
  phase = state.get("phase")
1027
1037
  locked = phase != "bootstrap"
@@ -1068,6 +1078,10 @@ def _qualitative(state):
1068
1078
  return _rules(state) >= SCOPE_RULES and state.get("method") == "qualitative"
1069
1079
 
1070
1080
 
1081
+ def _exploratory(state):
1082
+ return _rules(state) >= EXPLORE_RULES and state.get("method") == "exploratory"
1083
+
1084
+
1071
1085
  def _scope_items(state):
1072
1086
  scope = state.get("scope")
1073
1087
  items = scope.get("in") if isinstance(scope, dict) else None
@@ -1088,11 +1102,14 @@ def scope_problems(state):
1088
1102
  i for i in items if isinstance(i, str) and i.strip()]:
1089
1103
  problems.append("state.json scope.%s names nothing: list %s, from the "
1090
1104
  "user's own question" % (side, what))
1091
- if state.get("method") not in METHODS:
1105
+ # `exploratory` is a rules-7 method: on an older line it is as unknown as any
1106
+ # other word, and the message names the two that line can take
1107
+ methods = METHODS if _rules(state) >= EXPLORE_RULES else METHODS[:2]
1108
+ if state.get("method") not in methods:
1092
1109
  problems.append("state.json method %r is not one of %s: the question picks "
1093
1110
  "it - a source or reference question is qualitative and "
1094
- "owes no metric" % (state.get("method"), ", ".join(METHODS)))
1095
- return problems
1111
+ "owes no metric" % (state.get("method"), ", ".join(methods)))
1112
+ return problems + arena_problems(state)
1096
1113
 
1097
1114
 
1098
1115
  def trace_problem(cid, claim, state):
@@ -2877,6 +2894,10 @@ def _check_evaluation_order(repo, base, state, errors, warnings, strict):
2877
2894
  commit the history holds. `_check_evaluation` reads that the fields are filled;
2878
2895
  this reads when: a metric and a baseline committed after the results are the
2879
2896
  criterion the results were measured against after the fact."""
2897
+ if _exploratory(state):
2898
+ # the arena is an exploratory line's lock, and `_check_arena_order` orders
2899
+ # every results.jsonl against it the way this orders them against a metric
2900
+ return
2880
2901
  hard = strict or _rules(state) >= STANDARDS_RULES
2881
2902
  evaluation = state.get("evaluation")
2882
2903
  if not isinstance(evaluation, dict):
@@ -3406,6 +3427,93 @@ def _check_standards(repo, slug, base, errors, warnings, strict, git):
3406
3427
  if git:
3407
3428
  _check_evaluation_order(repo, base, state, errors, warnings, strict)
3408
3429
  _check_results_append_only(repo, base, state, errors, warnings, strict)
3430
+ _check_selection(base, state, warnings)
3431
+ if _exploratory(state):
3432
+ _check_exploratory(repo, base, state, errors, warnings, strict, git)
3433
+
3434
+
3435
+ # A protocol that states its repeats: `k: 3`, `repeats = 5`, `repeat: 2`.
3436
+ PROTOCOL_REPEATS = re.compile(r"(?i)\b(?:k|repeats?)\s*[:=]\s*(\d+)")
3437
+ # A number standing alone in a tolerance sentence; the 95 of `p95` is a name.
3438
+ # `spread` is in the tolerance's own unit, so a percentage, whose base the band
3439
+ # does not carry, is not compared: "within 5%" against spread 0.06 is no finding.
3440
+ TOLERANCE_NUMBER = re.compile(r"(?<![\w.])\d+(?:\.\d+)?(?![\d.]*\s*%)")
3441
+
3442
+
3443
+ def _count(value):
3444
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
3445
+
3446
+
3447
+ def _check_selection(base, state, warnings):
3448
+ """Rules 6, the rsi-borrow-screen includes (ideas 115/226, 110, 67), each a
3449
+ warning and never a refusal, `--strict` included (ADR 019: a refusal needs
3450
+ its own outcome evidence first).
3451
+
3452
+ - A decision with comparison cells and no `evaluation.held_out` selected its
3453
+ variant and reports it on the same cells; a candidate that consults the
3454
+ held-out set more than once (`held_out_log.jsonl`) turned it into
3455
+ development data.
3456
+ - An armed experiment (`arm` or `variant` with 2+ values in its rows) with no
3457
+ row `repeat`/`repeats`/`k` above 1 and no protocol stating one ran each arm
3458
+ once, so its difference has no spread to be read against.
3459
+ - A `capability_tolerance` owes an `evaluation.noise_band` - the experiment
3460
+ that reran the unchanged baseline and its measured `spread` - and a
3461
+ tolerance wider than that spread passes what noise alone would."""
3462
+ if _rules(state) < HOLDOUT_RULES or _qualitative(state):
3463
+ return
3464
+ evaluation = state.get("evaluation")
3465
+ evaluation = evaluation if isinstance(evaluation, dict) else {}
3466
+ held_out = evaluation.get("held_out")
3467
+ if not (isinstance(held_out, str) and held_out.strip()):
3468
+ same = [n for n, ddir in _decision_dirs(base)
3469
+ if _rows(os.path.join(ddir, "comparison.jsonl"))]
3470
+ if same:
3471
+ warnings.append("decisions %s selected and reported on the same cells: "
3472
+ "no state.json evaluation held_out names the set the "
3473
+ "selection is confirmed on" % ", ".join(same))
3474
+ consulted = {}
3475
+ for row in _row_objects(os.path.join(base, "held_out_log.jsonl")):
3476
+ key = (str(row.get("candidate")), str(row.get("set")))
3477
+ consulted[key] = consulted.get(key, 0) + 1
3478
+ for (candidate, held), n in sorted(consulted.items()):
3479
+ if n > 1:
3480
+ warnings.append("held_out_log.jsonl: %s consulted held-out set %s %d "
3481
+ "times, so the held-out set became development data for "
3482
+ "%s" % (candidate, held, n, candidate))
3483
+ names = _experiment_names(base)
3484
+ for h in names:
3485
+ d = os.path.join(base, "experiments", h)
3486
+ rows = _row_objects(os.path.join(d, "results.jsonl"))
3487
+ if not any(len({json.dumps(r[f], sort_keys=True) for r in rows if f in r}) > 1
3488
+ for f in ("arm", "variant")):
3489
+ continue
3490
+ if any(_count(r.get(f)) and r[f] > 1
3491
+ for r in rows for f in ("repeat", "repeats", "k")):
3492
+ continue
3493
+ text = _note_text(os.path.join(d, "protocol.md"))
3494
+ if any(int(m) > 1 for m in PROTOCOL_REPEATS.findall(text)):
3495
+ continue
3496
+ warnings.append("experiment %s compares arms with one run per arm: no row "
3497
+ "carries repeat/repeats/k above 1 and protocol.md states no "
3498
+ "k, so the difference has no spread to be read against" % h)
3499
+ tolerance = evaluation.get("capability_tolerance")
3500
+ band = evaluation.get("noise_band")
3501
+ if not isinstance(band, dict):
3502
+ if isinstance(tolerance, str) and tolerance.strip():
3503
+ warnings.append("state.json evaluation capability_tolerance %r declares no "
3504
+ "evaluation noise_band: the band was written, never "
3505
+ "measured against a rerun of the unchanged baseline"
3506
+ % tolerance)
3507
+ return
3508
+ if band.get("experiment") not in names:
3509
+ warnings.append("state.json evaluation noise_band experiment %r is not an "
3510
+ "experiment of this line" % (band.get("experiment"),))
3511
+ spread = band.get("spread")
3512
+ number = TOLERANCE_NUMBER.search(tolerance) if isinstance(tolerance, str) else None
3513
+ if number and _count(spread) and float(number.group()) > spread:
3514
+ warnings.append("state.json evaluation capability_tolerance %s is wider than "
3515
+ "the measured noise (noise_band spread %s)"
3516
+ % (number.group(), spread))
3409
3517
 
3410
3518
 
3411
3519
  def _check_review_integrity(base, errors, warnings, strict):
@@ -3442,10 +3550,13 @@ def _check_review_integrity(base, errors, warnings, strict):
3442
3550
  % (" and ".join(high), len(order), order[0][:80]))
3443
3551
 
3444
3552
 
3445
- def check_line(repo, slug, git=True, strict=False):
3553
+ def check_line(repo, slug, git=True, strict=False, code=None):
3446
3554
  """(errors, warnings) for one research line. `git=False` skips the checks that
3447
3555
  need git history, for callers that must stay cheap (the session context).
3448
- `strict=True` turns every guarantee the checker cannot decide into an error."""
3556
+ `strict=True` turns every guarantee the checker cannot decide into an error.
3557
+ `code` is the checkout the check runs in when it is not `repo`: the line
3558
+ lives in the main checkout's workspace (plan 097) and its citations and
3559
+ proofs resolve against the checkout whose branch holds the code."""
3449
3560
  base = line_dir(repo, slug)
3450
3561
  errors, warnings = [], []
3451
3562
  if not os.path.isdir(base):
@@ -3458,7 +3569,8 @@ def check_line(repo, slug, git=True, strict=False):
3458
3569
  errors.append("%s is missing" % name)
3459
3570
  _check_state(base, errors, warnings, strict)
3460
3571
  _check_findings(base, errors, warnings, strict)
3461
- _check_claims(base, errors, warnings, roots=(repo,), strict=strict)
3572
+ _check_claims(base, errors, warnings, roots=(repo, code) if code and code != repo
3573
+ else (repo,), strict=strict)
3462
3574
  _check_predictions(repo, base, errors, warnings, strict, git)
3463
3575
  seal_errors, intact = _check_seal(base)
3464
3576
  errors.extend(seal_errors)
@@ -3577,7 +3689,7 @@ def _check_derived_phase(base, phase, direction, warnings):
3577
3689
  "stopped describing the line" % (direction, derived, want))
3578
3690
 
3579
3691
 
3580
- def check(repo, slug=None, git=True, strict=False, all_lines=True):
3692
+ def check(repo, slug=None, git=True, strict=False, all_lines=True, code=None):
3581
3693
  """{slug: {"errors": [...], "warnings": [...]}} for one line or every line.
3582
3694
 
3583
3695
  With `all_lines=False` and no slug, a line under `done/` is read by its order
@@ -3591,7 +3703,7 @@ def check(repo, slug=None, git=True, strict=False, all_lines=True):
3591
3703
  if errors:
3592
3704
  out[name] = {"errors": errors, "warnings": []}
3593
3705
  continue
3594
- errors, warnings = check_line(repo, name, git=git, strict=strict)
3706
+ errors, warnings = check_line(repo, name, git=git, strict=strict, code=code)
3595
3707
  out[name] = {"errors": errors, "warnings": warnings}
3596
3708
  return out
3597
3709
 
@@ -4782,7 +4894,7 @@ def verdict_problems(state, rows):
4782
4894
 
4783
4895
 
4784
4896
  def init(repo, slug, question="", created="", supersedes=None, ask="", tier=DEFAULT_TIER,
4785
- method="", scope_in=(), scope_out=()):
4897
+ method="", scope_in=(), scope_out=(), arena=None):
4786
4898
  """Scaffold a research line. Returns the paths created (never overwrites).
4787
4899
 
4788
4900
  Refuses a slug `slugs()` cannot list - see `valid_slug` - so no caller can
@@ -4808,6 +4920,11 @@ def init(repo, slug, question="", created="", supersedes=None, ask="", tier=DEFA
4808
4920
  state["tier"] = tier
4809
4921
  if supersedes:
4810
4922
  state["supersedes"] = supersedes
4923
+ if arena is not None:
4924
+ # an exploratory line's lock (`arena_record`); absent on every other line.
4925
+ # The campaign delivers its staged portfolio as findings, never variants.
4926
+ state["arena"] = arena
4927
+ state["deliverable"] = {"kind": "finding"}
4811
4928
  files = {
4812
4929
  "state.json": json.dumps(state, indent=2) + "\n",
4813
4930
  "findings.md": FINDINGS_TEMPLATE,
@@ -5270,3 +5387,1175 @@ def components_text(report):
5270
5387
  out.append(header("(no components)", None, report["unlabeled"]))
5271
5388
  out.extend(row_line(row) for row in report["unlabeled"])
5272
5389
  return out
5390
+
5391
+
5392
+ # --- exploratory lines (rules 7) ----------------------------------------------
5393
+ # Every other line answers one question. An exploratory line generates: a
5394
+ # campaign over an arena locked before its first idea, whose ideas pass a funnel
5395
+ # where each stage costs more than the one before - dedup, prior art, a judged
5396
+ # tournament, a pilot, a full run, a confirmation on data the selection never saw.
5397
+ # The literature it is built on (docs/research.md, "Rules 7") says generation is
5398
+ # cheap and verification is the bottleneck: about 5% of 4,000 generated ideas were
5399
+ # non-duplicates (arXiv 2409.04109), AI ideas lost more than human ones once
5400
+ # executed (2506.20803), 24% of "novel" AI proposals were borrowed (2502.16487),
5401
+ # an LLM's novelty score ran against realised impact (2603.15164), and a
5402
+ # self-improving loop removed its own detection markers (2505.22954). So a
5403
+ # ranking is a prior and never a verdict, a run under the locked evaluator is the
5404
+ # judge, prior art is checked per idea, and the number of ideas tested is said.
5405
+ #
5406
+ # Three append-only files hold the campaign, each row type judged by one function
5407
+ # the writer and the checker share (`idea_problems`, `stage_problems`,
5408
+ # `match_problems`); the stage an idea is in is its newest stages.jsonl row, and
5409
+ # nothing stores a status. A line under rules 1-6, or a line whose method is not
5410
+ # `exploratory`, reads none of it.
5411
+ IDEAS_FILE = "ideas.jsonl"
5412
+ STAGES_FILE = "stages.jsonl"
5413
+ TOURNAMENT_FILE = "tournament.jsonl"
5414
+ CAMPAIGN_FILES = (IDEAS_FILE, STAGES_FILE, TOURNAMENT_FILE)
5415
+ # The move that produced an idea, closed so the yield report can count per move;
5416
+ # `user` is an idea the person asked for, and `mutation`/`crossover` derive from
5417
+ # earlier ideas (`PARENT_OPERATORS`: how many parents each takes at least).
5418
+ OPERATORS = ("tension", "hidden-constraint", "analogy", "bisociation", "inversion",
5419
+ "ablate-sota", "scale-shift", "failure-mining", "replicate-extend",
5420
+ "literature-bridge", "mutation", "crossover", "user")
5421
+ PARENT_OPERATORS = {"mutation": 1, "crossover": 2}
5422
+ IDEA_FIELDS = ("id", "statement", "mechanism", "trace", "operator", "generator",
5423
+ "kill", "date")
5424
+ # In funnel order; an idea moves forward only, or to `killed` from anywhere, and
5425
+ # `killed` is final. The idea row is its `proposed` stage, so no stage row says it.
5426
+ STAGES = ("proposed", "duplicate", "prior-art", "screened", "piloted", "promoted",
5427
+ "confirmed", "killed")
5428
+ PRIOR_ART_VERDICTS = ("novel", "incremental", "known")
5429
+ # The stage each stage needs somewhere in the idea's history, beside moving
5430
+ # forward: prior art is checked per idea before any judging, runs are spent only
5431
+ # on screened ideas, a ranking never stands in for a pilot, and a confirmation
5432
+ # confirms a selection that happened.
5433
+ PREREQUISITES = {"screened": "prior-art", "piloted": "screened",
5434
+ "promoted": "piloted", "confirmed": "promoted"}
5435
+ MATCH_ORDERS = ("ab", "ba")
5436
+ MATCH_WINNERS = ("a", "b", "tie")
5437
+ IDEA_ID = re.compile(r"I[1-9][0-9]*")
5438
+ SHA256_HEX = re.compile(r"[0-9a-f]{64}")
5439
+ NEAREST_MIN = 3
5440
+ # Si et al. (arXiv 2409.04109) called two seed ideas duplicates at cosine 0.8 on
5441
+ # MiniLM. That number does not carry to another similarity, on either of two label
5442
+ # sets: 40 idea-statement pairs labelled by the author (fixture, 20 duplicates) and
5443
+ # 400 human-labelled MRPC test pairs (200 paraphrases; docs/research.md). At 0.8,
5444
+ # F1 was 0.62 and 0.64 for embed-mrl and 0.09 and 0.41 for BM25. Each measured
5445
+ # backend takes the threshold whose worse F1 over the two sets is highest: embed-mrl
5446
+ # 0.65 (fixture 0.90, MRPC 0.705), BM25 0.40 (fixture 0.70, MRPC 0.69). Any other
5447
+ # backend keeps Si et al.'s, named as uncalibrated. `--threshold` wins.
5448
+ DEDUP_THRESHOLD = 0.8
5449
+ DEDUP_THRESHOLDS = {"bm25": 0.40, "embed:embed-mrl": 0.65}
5450
+ # The share of the next campaign's operator draws kept uniform, so a move that
5451
+ # has not paid off yet is still tried (`operator_weights`).
5452
+ EXPLORE_SHARE = 0.2
5453
+
5454
+
5455
+ def _text(value):
5456
+ return isinstance(value, str) and bool(value.strip())
5457
+
5458
+
5459
+ def _is_idea(value, ideas):
5460
+ """True when a row field names an idea; a list or an object there is not an id
5461
+ and must not reach a dict lookup."""
5462
+ return isinstance(value, str) and value in ideas
5463
+
5464
+
5465
+ def _sentences(text):
5466
+ return len([s for s in re.split(r"(?<=[.!?])\s+", str(text).strip()) if s])
5467
+
5468
+
5469
+ def _norm(text):
5470
+ return " ".join(str(text or "").lower().split())
5471
+
5472
+
5473
+ def _who_problem(value):
5474
+ """None when `value` names a model as the host reports it - `{family, model}`
5475
+ - else what it is missing."""
5476
+ if not isinstance(value, dict):
5477
+ return "is %s, not the object {family, model}" % type(value).__name__
5478
+ missing = [k for k in ("family", "model") if not _text(value.get(k))]
5479
+ return "names no %s" % " or ".join(missing) if missing else None
5480
+
5481
+
5482
+ def _family(value):
5483
+ return value["family"].strip() if isinstance(value, dict) \
5484
+ and _text(value.get("family")) else None
5485
+
5486
+
5487
+ def _idea_number(iid):
5488
+ """The sort key of an idea id: I2 before I10, and anything else last."""
5489
+ return (int(iid[1:]), iid) if isinstance(iid, str) and IDEA_ID.fullmatch(iid) \
5490
+ else (10 ** 9, str(iid))
5491
+
5492
+
5493
+ def arena_record(repo, problem, budget, evaluator="", metric=""):
5494
+ """The `state.json` `arena` an exploratory line locks before its first idea: the
5495
+ problem in the user's words and the budget, and for a measurable arena the
5496
+ evaluator's repository path, the sha256 of its bytes now and the metric it
5497
+ returns. Raises OSError when the evaluator cannot be read."""
5498
+ arena = {"problem": problem, "budget": budget}
5499
+ if evaluator:
5500
+ arena.update({"evaluator": evaluator,
5501
+ "evaluator_sha256": _digest(os.path.join(repo, evaluator)),
5502
+ "metric": metric})
5503
+ return arena
5504
+
5505
+
5506
+ def arena_problems(state):
5507
+ """What an exploratory line's arena is missing; [] when it is locked, and []
5508
+ for every other line. Read through `scope_problems`, so it is refused past
5509
+ `bootstrap` and warned at it like the rest of the lock."""
5510
+ if not _exploratory(state):
5511
+ return []
5512
+ arena = state.get("arena")
5513
+ if not isinstance(arena, dict):
5514
+ return ["state.json arena is %s, not the object that locks the problem, the "
5515
+ "budget and the evaluator before the first idea"
5516
+ % ("absent" if arena is None else type(arena).__name__)]
5517
+ problems = []
5518
+ for key, what in (("problem", "the problem in the user's words"),
5519
+ ("budget", "the ideas, pilots, full runs and wall time it may "
5520
+ "spend")):
5521
+ if not _text(arena.get(key)):
5522
+ problems.append("state.json arena names no %s: write %s before the first "
5523
+ "idea" % (key, what))
5524
+ evaluator = arena.get("evaluator")
5525
+ if evaluator not in (None, ""):
5526
+ if not _text(evaluator):
5527
+ problems.append("state.json arena evaluator %r is not a path" % (evaluator,))
5528
+ if not SHA256_HEX.fullmatch(str(arena.get("evaluator_sha256") or "")):
5529
+ problems.append("state.json arena evaluator %s carries no evaluator_sha256: "
5530
+ "the hash is what locks it" % (evaluator,))
5531
+ if not _text(arena.get("metric")):
5532
+ problems.append("state.json arena evaluator %s names no metric: say what "
5533
+ "number it returns" % (evaluator,))
5534
+ return problems
5535
+
5536
+
5537
+ def evaluator_problem(repo, state):
5538
+ """The refusal every stage row takes while the arena's evaluator no longer
5539
+ hashes as locked, or None (also for an arena with no evaluator). The candidate
5540
+ a campaign judges must not be able to move the judge: the Darwin Godel Machine
5541
+ removed its own detection markers when nothing pinned them."""
5542
+ arena = state.get("arena") if isinstance(state.get("arena"), dict) else {}
5543
+ path = arena.get("evaluator")
5544
+ if not _text(path):
5545
+ return None
5546
+ try:
5547
+ now = _digest(os.path.join(repo, path))
5548
+ except OSError:
5549
+ now = None
5550
+ locked = str(arena.get("evaluator_sha256") or "")
5551
+ if now == locked:
5552
+ return None
5553
+ return ("the arena evaluator %s %s since the arena was locked (sha256 %s): a stage "
5554
+ "judged by another evaluator is not this campaign's - restore it, or close "
5555
+ "the line and lock a new arena"
5556
+ % (path, "is gone" if now is None else "changed (now %s)" % now[:12],
5557
+ locked[:12] or "none"))
5558
+
5559
+
5560
+ def _campaign_rows(path, label):
5561
+ """(rows, problems, fragment) of one campaign file: [(line number, object)] up to
5562
+ the committed boundary, a problem per terminated line that does not parse or is
5563
+ not an object, and the bytes past the boundary a killed writer left (the next
5564
+ append truncates them). An absent file is no rows and no problem."""
5565
+ if not os.path.exists(path):
5566
+ return [], [], 0
5567
+ text, exc = _committed_text(path)
5568
+ if exc:
5569
+ return [], ["%s cannot be read (%s)" % (label, exc)], 0
5570
+ rows, problems = [], []
5571
+ for n, raw in enumerate(text.splitlines(), 1):
5572
+ if not raw.strip():
5573
+ continue
5574
+ try:
5575
+ row = json.loads(raw)
5576
+ except ValueError as err:
5577
+ problems.append("%s:%d does not parse (%s)" % (label, n, err))
5578
+ continue
5579
+ if not isinstance(row, dict):
5580
+ problems.append("%s:%d is a %s, not the object a row is"
5581
+ % (label, n, type(row).__name__))
5582
+ continue
5583
+ rows.append((n, row))
5584
+ try:
5585
+ fragment = os.path.getsize(path) - len(text.encode("utf-8"))
5586
+ except OSError:
5587
+ fragment = 0
5588
+ return rows, problems, fragment
5589
+
5590
+
5591
+ def _rows_of(base, name):
5592
+ return [row for _n, row in _campaign_rows(os.path.join(base, name), name)[0]]
5593
+
5594
+
5595
+ def _handle_rows(handle):
5596
+ """The objects of an open campaign file up to its committed boundary: what an
5597
+ append judges against, read from the descriptor it holds the lock on."""
5598
+ size = committed_size(handle)
5599
+ handle.seek(0)
5600
+ out = []
5601
+ for raw in handle.read(size).decode("utf-8", "replace").splitlines():
5602
+ try:
5603
+ row = json.loads(raw)
5604
+ except ValueError:
5605
+ continue
5606
+ if isinstance(row, dict):
5607
+ out.append(row)
5608
+ return out
5609
+
5610
+
5611
+ def _in_scope(entry, state):
5612
+ entry = entry.strip()
5613
+ return any(entry in ("S%d" % n, item.strip())
5614
+ for n, item in enumerate(_scope_items(state), 1))
5615
+
5616
+
5617
+ def idea_problems(row, state, held):
5618
+ """The reasons one idea row cannot stand; [] when it can. `held` is the idea rows
5619
+ before it in ideas.jsonl. The writer (`append_idea`, under the lock) and `check`
5620
+ both call it, so one row gets one verdict."""
5621
+ if not isinstance(row, dict):
5622
+ return ["an idea row is a JSON object, not a %s" % type(row).__name__]
5623
+ iid = row["id"] if _text(row.get("id")) else "(no id)"
5624
+ problems = []
5625
+ arena = arena_problems(state)
5626
+ if arena:
5627
+ problems.append("idea %s: no arena is locked (%s) - the arena is written and "
5628
+ "committed before the first idea" % (iid, arena[0]))
5629
+ problems.extend("idea %s carries no %s" % (iid, key) for key in IDEA_FIELDS
5630
+ if key != "generator" and not _text(row.get(key)))
5631
+ who = _who_problem(row.get("generator"))
5632
+ if who:
5633
+ problems.append("idea %s generator %s: name the family and the model id as "
5634
+ "the host reports it" % (iid, who))
5635
+ ids = [r.get("id") for r in held]
5636
+ if _text(row.get("id")):
5637
+ if not IDEA_ID.fullmatch(row["id"]):
5638
+ problems.append("idea id %r is not I<n>" % row["id"])
5639
+ elif row["id"] in ids:
5640
+ problems.append("idea %s is already in ideas.jsonl: a new idea takes a new "
5641
+ "id" % iid)
5642
+ op = row.get("operator")
5643
+ if _text(op) and op not in OPERATORS:
5644
+ problems.append("idea %s operator %r is not one of %s"
5645
+ % (iid, op, ", ".join(OPERATORS)))
5646
+ parents = row.get("parents")
5647
+ if parents is not None and (not isinstance(parents, list)
5648
+ or not all(_text(p) for p in parents)):
5649
+ problems.append("idea %s parents is not a list of idea ids" % iid)
5650
+ parents = []
5651
+ problems.extend("idea %s names parent %s, and no earlier idea carries that id"
5652
+ % (iid, p) for p in parents or [] if p not in ids)
5653
+ need = PARENT_OPERATORS.get(op) if isinstance(op, str) else None
5654
+ if need and len(set(parents or [])) < need:
5655
+ problems.append("idea %s is a %s and names %d parent(s): it takes at least %d"
5656
+ % (iid, op, len(set(parents or [])), need))
5657
+ if _text(row.get("statement")) and _sentences(row["statement"]) > 2:
5658
+ problems.append("idea %s is stated in %d sentences: two at most - an idea that "
5659
+ "does not fit in two is not one idea yet"
5660
+ % (iid, _sentences(row["statement"])))
5661
+ if _text(row.get("date")) and not ISO_DATE.fullmatch(row["date"].strip()):
5662
+ problems.append("idea %s date %r is not YYYY-MM-DD" % (iid, row["date"]))
5663
+ if _text(row.get("trace")) and not _in_scope(row["trace"], state):
5664
+ problems.append(
5665
+ "idea %s traces to %s, and state.json scope.in names nothing: open the line "
5666
+ "with --in for what the arena covers" % (iid, row["trace"])
5667
+ if not _scope_items(state) else
5668
+ "idea %s traces to no in-scope item (%s): an idea outside the arena is one "
5669
+ "line under Open questions in findings.md, never a row" % (iid, row["trace"]))
5670
+ return problems
5671
+
5672
+
5673
+ def _verdict(row):
5674
+ evidence = row.get("evidence")
5675
+ return evidence.get("verdict") if isinstance(evidence, dict) else None
5676
+
5677
+
5678
+ def _experiment_ref(value):
5679
+ """The experiment directory name a stage's evidence names: `h` or
5680
+ `experiments/h`; None for anything else."""
5681
+ if not _text(value):
5682
+ return None
5683
+ name = value.strip().rstrip("/")
5684
+ if name.startswith("experiments/"):
5685
+ name = name[len("experiments/"):]
5686
+ return None if not name or "/" in name or name.startswith(".") else name
5687
+
5688
+
5689
+ def _index_classes(base):
5690
+ """{note: class} of the line's literature/INDEX.jsonl, at its committed boundary."""
5691
+ text, exc = _committed_text(os.path.join(base, "literature", "INDEX.jsonl"))
5692
+ out = {}
5693
+ for raw in (text or "").splitlines() if not exc else []:
5694
+ try:
5695
+ row = json.loads(raw)
5696
+ except ValueError:
5697
+ continue
5698
+ if isinstance(row, dict) and _text(row.get("note")):
5699
+ out[row["note"].strip()] = row.get("class")
5700
+ return out
5701
+
5702
+
5703
+ def _prior_art_problems(label, evidence, base):
5704
+ if not isinstance(evidence, dict):
5705
+ return ["%s: the evidence is {verdict, nearest, delta}" % label]
5706
+ problems = []
5707
+ if evidence.get("verdict") not in PRIOR_ART_VERDICTS:
5708
+ problems.append("%s verdict %r is not one of %s" % (
5709
+ label, evidence.get("verdict"), ", ".join(PRIOR_ART_VERDICTS)))
5710
+ nearest = evidence.get("nearest")
5711
+ notes = [n.strip()[len("literature/"):] if n.strip().startswith("literature/")
5712
+ else n.strip() for n in nearest if _text(n)] \
5713
+ if isinstance(nearest, list) else []
5714
+ if len(set(notes)) < NEAREST_MIN:
5715
+ problems.append("%s names %d nearest work(s): at least %d notes, each indexed "
5716
+ "in literature/INDEX.jsonl" % (label, len(set(notes)),
5717
+ NEAREST_MIN))
5718
+ classes = _index_classes(base)
5719
+ missing = [n for n in notes if n not in classes]
5720
+ if missing:
5721
+ problems.append("%s names %s, which literature/INDEX.jsonl does not index"
5722
+ % (label, ", ".join(missing)))
5723
+ elif notes and all(classes[n] == "agent-report" for n in notes):
5724
+ problems.append("%s rests on agent-report notes only: an agent's summary is not "
5725
+ "the work it summarises" % label)
5726
+ if not _text(evidence.get("delta")) or _sentences(evidence["delta"]) > 1:
5727
+ problems.append("%s: delta is one sentence saying what the idea does "
5728
+ "differently from the nearest work" % label)
5729
+ return problems
5730
+
5731
+
5732
+ def _run_problems(label, h, base, repo, idea, git):
5733
+ """A pilot, a full run or a confirmation: an experiment of the line with a
5734
+ protocol and a results row, whose protocol is committed (`git`); a pilot's or a
5735
+ full run's protocol names the idea and repeats its kill criterion (`idea`)."""
5736
+ if h is None:
5737
+ return ["%s names no experiment of this line" % label]
5738
+ d = os.path.join(base, "experiments", h)
5739
+ proto = os.path.join(d, "protocol.md")
5740
+ if not os.path.isfile(proto):
5741
+ return ["%s: experiments/%s has no protocol.md" % (label, h)]
5742
+ problems = []
5743
+ if not _rows(os.path.join(d, "results.jsonl")):
5744
+ problems.append("%s: experiments/%s recorded no results row, so nothing ran"
5745
+ % (label, h))
5746
+ if idea is not None:
5747
+ text = _note_text(proto)
5748
+ if not _mentions(str(idea.get("id")), text):
5749
+ problems.append("%s: experiments/%s/protocol.md does not name %s"
5750
+ % (label, h, idea.get("id")))
5751
+ if _norm(idea.get("kill")) not in _norm(text):
5752
+ problems.append("%s: experiments/%s/protocol.md does not repeat %s's kill "
5753
+ "criterion (%s)" % (label, h, idea.get("id"),
5754
+ idea.get("kill")))
5755
+ if git:
5756
+ adds, err = _oldest_adds(repo, proto)
5757
+ if not err and not any(adds.values()):
5758
+ problems.append("%s: experiments/%s/protocol.md is not committed, so it "
5759
+ "cannot show the plan came before the run" % (label, h))
5760
+ elif not err:
5761
+ done, done_err = _oldest_adds(repo, os.path.join(d, "results.jsonl"))
5762
+ if not done_err and any(done.values()) \
5763
+ and False in _answers(repo, adds, done).values():
5764
+ problems.append("%s: experiments/%s/protocol.md did not enter the "
5765
+ "history before its results.jsonl, so it predicts "
5766
+ "nothing" % (label, h))
5767
+ return problems
5768
+
5769
+
5770
+ def _confirm_problems(label, row, base, repo, state, history, git):
5771
+ evidence = row.get("evidence")
5772
+ if not isinstance(evidence, dict):
5773
+ return ["%s: the evidence is {experiment, set}: the run, and the held-out set "
5774
+ "held_out_log.jsonl shows nothing looked at first" % label]
5775
+ iid, problems = row["idea"], []
5776
+ arena = state.get("arena") if isinstance(state.get("arena"), dict) else {}
5777
+ if not _text(arena.get("evaluator")):
5778
+ problems.append("%s: the arena has no evaluator, so it is judged by review only "
5779
+ "and no idea reaches confirmed" % label)
5780
+ h = _experiment_ref(evidence.get("experiment"))
5781
+ promoted = [_experiment_ref(r.get("evidence")) for r in history
5782
+ if r.get("stage") == "promoted"]
5783
+ if h in promoted:
5784
+ problems.append("%s: experiment %s is the run that promoted %s - a confirmation "
5785
+ "runs on data the selection never saw" % (label, h, iid))
5786
+ problems.extend(_run_problems(label, h, base, repo, None, git))
5787
+ held = evidence.get("set")
5788
+ if not _text(held):
5789
+ problems.append("%s names no held-out set" % label)
5790
+ return problems
5791
+ looks = [r for r in _row_objects(os.path.join(base, "held_out_log.jsonl"))
5792
+ if str(r.get("set")) == held]
5793
+ # a look dated before the promotion was part of the selection; dates, unlike
5794
+ # positions in two files, order the log against the stage rows, and a look
5795
+ # appended after the confirmation never changes the verdict
5796
+ since = max((str(r.get("date")) for r in history if r.get("stage") == "promoted"
5797
+ and ISO_DATE.fullmatch(str(r.get("date") or ""))), default="")
5798
+ mine = [str(r.get("date") or "") for r in looks if str(r.get("candidate")) == iid]
5799
+ if not looks:
5800
+ problems.append("%s: held_out_log.jsonl records no look at set %s - log the "
5801
+ "confirmation's look before the stage" % (label, held))
5802
+ elif str(looks[0].get("candidate")) != iid:
5803
+ problems.append("%s: held_out_log.jsonl shows %s looked at set %s first, so the "
5804
+ "set was looked at during selection"
5805
+ % (label, looks[0].get("candidate"), held))
5806
+ elif any(not ISO_DATE.fullmatch(d) for d in mine):
5807
+ problems.append("%s: a held_out_log.jsonl look of %s at set %s carries no "
5808
+ "YYYY-MM-DD date, so it cannot be placed against the promotion"
5809
+ % (label, iid, held))
5810
+ elif any(d < since for d in mine):
5811
+ problems.append("%s: held_out_log.jsonl shows %s looked at set %s before its "
5812
+ "promotion (%s), so the set was looked at during selection"
5813
+ % (label, iid, held, since))
5814
+ return problems
5815
+
5816
+
5817
+ def stage_problems(row, base, repo, state, ideas, history, matches, evaluator=None,
5818
+ git=True):
5819
+ """The reasons one stage row cannot stand; [] when it can. `ideas` maps each id
5820
+ to its idea row, `history` is that idea's earlier stage rows in file order,
5821
+ `matches` the tournament rows, `evaluator` what `evaluator_problem` says now,
5822
+ and `git` whether the order half is asked (the session note does not pay for
5823
+ it). The writer (`append_stage`, under the lock) and `check` both call it."""
5824
+ if not isinstance(row, dict):
5825
+ return ["a stage row is a JSON object, not a %s" % type(row).__name__]
5826
+ iid, stage = row.get("idea"), row.get("stage")
5827
+ label = "stage %s %s" % (iid if _text(iid) else "(no idea)",
5828
+ stage if _text(stage) else "(no stage)")
5829
+ problems = ["%s: %s" % (label, evaluator)] if evaluator else []
5830
+ if not _text(iid) or iid not in ideas:
5831
+ return problems + ["%s names no idea ideas.jsonl carries" % label]
5832
+ if stage not in STAGES:
5833
+ return problems + ["%s: %r is not one of %s" % (label, stage, ", ".join(STAGES))]
5834
+ if not (_text(row.get("date")) and ISO_DATE.fullmatch(row["date"].strip())):
5835
+ problems.append("%s carries no YYYY-MM-DD date" % label)
5836
+ current = history[-1].get("stage") if history else "proposed"
5837
+ if stage == "proposed":
5838
+ return problems + ["%s: an idea is proposed by its ideas.jsonl row, so no stage "
5839
+ "row says it" % label]
5840
+ if current == "killed":
5841
+ return problems + ["%s: %s is already killed, and killed is final - a revived "
5842
+ "idea is a new idea naming this one as its parent"
5843
+ % (label, iid)]
5844
+ if current == "prior-art" and _verdict(history[-1]) == "known" and stage != "killed":
5845
+ return problems + ["%s: %s's prior art is known, so the only row after it is "
5846
+ "killed" % (label, iid)]
5847
+ if current == "duplicate" and stage != "killed":
5848
+ return problems + ["%s: %s is a duplicate, so the only row after it is killed"
5849
+ % (label, iid)]
5850
+ if stage == "killed":
5851
+ if not _text(row.get("reason")):
5852
+ problems.append("%s carries no reason: a kill says what killed it" % label)
5853
+ return problems
5854
+ if current in STAGES and STAGES.index(stage) <= STAGES.index(current):
5855
+ return problems + ["%s: %s is at %s, and a stage moves forward only (or to "
5856
+ "killed)" % (label, iid, current)]
5857
+ need = PREREQUISITES.get(stage)
5858
+ if need and not any(r.get("stage") == need for r in history):
5859
+ return problems + ["%s: %s has no %s row, and %s comes only after it"
5860
+ % (label, iid, need, stage)]
5861
+ evidence = row.get("evidence")
5862
+ if stage == "duplicate":
5863
+ if not isinstance(evidence, dict):
5864
+ return problems + ["%s: the evidence is {of, similarity}: the idea it "
5865
+ "duplicates and the similarity measured" % label]
5866
+ if not _is_idea(evidence.get("of"), ideas) or evidence.get("of") == iid:
5867
+ problems.append("%s duplicates %r, which is not another idea of this line"
5868
+ % (label, evidence.get("of")))
5869
+ sim = evidence.get("similarity")
5870
+ if not (_count(sim) and 0 <= sim <= 1):
5871
+ problems.append("%s similarity %r is not a number in 0..1" % (label, sim))
5872
+ elif stage == "prior-art":
5873
+ problems.extend(_prior_art_problems(label, evidence, base))
5874
+ elif stage == "screened":
5875
+ if not any(iid in (m.get("a"), m.get("b")) for m in matches):
5876
+ problems.append("%s: tournament.jsonl holds no match naming %s, so nothing "
5877
+ "screened it" % (label, iid))
5878
+ elif stage in ("piloted", "promoted"):
5879
+ problems.extend(_run_problems(label, _experiment_ref(evidence), base, repo,
5880
+ ideas[iid], git))
5881
+ else:
5882
+ problems.extend(_confirm_problems(label, row, base, repo, state, history, git))
5883
+ return problems
5884
+
5885
+
5886
+ def match_problems(row, ideas):
5887
+ """The reasons one tournament row cannot stand; [] when it can. The writer
5888
+ (`append_match`) and `check` both call it. What the judge saw is the row's own
5889
+ business; which judges a pair still needs is a warning (`_tournament_warnings`),
5890
+ because a ranking is a prior and never a gate."""
5891
+ if not isinstance(row, dict):
5892
+ return ["a match row is a JSON object, not a %s" % type(row).__name__]
5893
+ a, b = row.get("a"), row.get("b")
5894
+ label = "match %s v %s" % (a, b)
5895
+ problems = ["%s: %s names %r, which is not an idea of this line"
5896
+ % (label, side, row.get(side))
5897
+ for side in ("a", "b") if not _is_idea(row.get(side), ideas)]
5898
+ if a == b and _is_idea(a, ideas):
5899
+ problems.append("%s matches an idea against itself" % label)
5900
+ who = _who_problem(row.get("judge"))
5901
+ if who:
5902
+ problems.append("%s judge %s" % (label, who))
5903
+ if row.get("order") not in MATCH_ORDERS:
5904
+ problems.append("%s order %r is not one of %s" % (label, row.get("order"),
5905
+ ", ".join(MATCH_ORDERS)))
5906
+ if row.get("winner") not in MATCH_WINNERS:
5907
+ problems.append("%s winner %r is not one of %s" % (label, row.get("winner"),
5908
+ ", ".join(MATCH_WINNERS)))
5909
+ if not _text(row.get("rationale")):
5910
+ problems.append("%s carries no rationale" % label)
5911
+ return problems
5912
+
5913
+
5914
+ def _valid_matches(matches, ideas):
5915
+ """The tournament rows `match_problems` accepts: the one read point every
5916
+ report and warning takes its matches from, so a hand-edited row of the wrong
5917
+ shape is the refusal `check` prints and never a crash further on."""
5918
+ return [m for m in matches if isinstance(m, dict) and not match_problems(m, ideas)]
5919
+
5920
+
5921
+ def _campaign_line(repo, slug):
5922
+ """(base, state, refusal) for the exploratory line a campaign row goes to.
5923
+ Raises FileNotFoundError when the line does not exist."""
5924
+ if not valid_slug(slug):
5925
+ return None, {}, "not a research slug: %r" % slug
5926
+ base = line_dir(repo, slug)
5927
+ if not os.path.isdir(base):
5928
+ raise FileNotFoundError(base)
5929
+ state = _state(base)
5930
+ refusal = newer_rules(state)
5931
+ if refusal is None and not _exploratory(state):
5932
+ refusal = ("%s is not an exploratory line (method %r, rules %d): ideas, stages "
5933
+ "and matches are rows of an exploratory campaign"
5934
+ % (slug, state.get("method"), _rules(state)))
5935
+ return base, state, refusal
5936
+
5937
+
5938
+ def _append_campaign(path, label, judge):
5939
+ """[] once the rows `judge` returned were appended, else the problems. `judge`
5940
+ takes the rows already in the file, read from the descriptor holding the lock,
5941
+ and returns (problems, rows to append): the judgement and the append meet at
5942
+ one instant and at the committed boundary, as every append here does. A refused
5943
+ append leaves no empty file behind."""
5944
+ created = not os.path.isfile(path)
5945
+ with open(path, "a+b") as handle:
5946
+ problem = _locked(handle, label)
5947
+ if problem:
5948
+ return [problem]
5949
+ problems, rows = judge(_handle_rows(handle))
5950
+ if problems:
5951
+ if created and os.path.getsize(path) == 0:
5952
+ os.remove(path)
5953
+ return problems
5954
+ truncate_to_committed(handle)
5955
+ handle.write(b"".join(json.dumps(r).encode("utf-8") + b"\n" for r in rows))
5956
+ return []
5957
+
5958
+
5959
+ def append_idea(repo, slug, row):
5960
+ """(id, problems): one idea appended to ideas.jsonl, judged under the lock by
5961
+ `idea_problems`; problems is [] exactly when it was written."""
5962
+ rid = row.get("id") if isinstance(row, dict) else None
5963
+ base, state, refusal = _campaign_line(repo, slug)
5964
+ if refusal:
5965
+ return rid, [refusal]
5966
+ return rid, _append_campaign(os.path.join(base, IDEAS_FILE), IDEAS_FILE,
5967
+ lambda held: (idea_problems(row, state, held), [row]))
5968
+
5969
+
5970
+ def append_stage(repo, slug, row, kill_reason=None, git=True):
5971
+ """Problems, [] exactly when the stage row was written. A `prior-art` row whose
5972
+ verdict is `known` is appended with the `killed` row it owes, under the same
5973
+ lock and judged in sequence, so no file state ever holds a known idea alive;
5974
+ without `kill_reason` it is refused."""
5975
+ base, state, refusal = _campaign_line(repo, slug)
5976
+ if refusal:
5977
+ return [refusal]
5978
+ if not isinstance(row, dict):
5979
+ return stage_problems(row, base, repo, state, {}, [], [])
5980
+ ideas = {r["id"]: r for r in _rows_of(base, IDEAS_FILE) if _text(r.get("id"))}
5981
+ matches = _valid_matches(_rows_of(base, TOURNAMENT_FILE), ideas)
5982
+ evaluator = evaluator_problem(repo, state)
5983
+ if git:
5984
+ # the hash in the working state.json is the one a session can rewrite: while
5985
+ # the arena differs from the committed lock, the line is refused, and so is
5986
+ # every stage row written meanwhile
5987
+ locked = _arena_lock(repo, base)[1]
5988
+ if locked is not None and state.get("arena") != locked:
5989
+ return ["state.json arena changed after the lock, so no stage is recorded "
5990
+ "until it is restored"]
5991
+
5992
+ def judge(rows):
5993
+ history = [r for r in rows if r.get("idea") == row.get("idea")]
5994
+ problems = stage_problems(row, base, repo, state, ideas, history, matches,
5995
+ evaluator, git)
5996
+ if problems or row.get("stage") != "prior-art" or _verdict(row) != "known":
5997
+ return problems, [row]
5998
+ if not _text(kill_reason):
5999
+ return ["stage %s prior-art: a known idea is killed in the same append - "
6000
+ "pass --reason with what already does it" % row.get("idea")], []
6001
+ kill = {"idea": row["idea"], "stage": "killed", "reason": kill_reason,
6002
+ "evidence": "prior-art known", "date": row.get("date")}
6003
+ return stage_problems(kill, base, repo, state, ideas, history + [row], matches,
6004
+ evaluator, git), [row, kill]
6005
+
6006
+ return _append_campaign(os.path.join(base, STAGES_FILE), STAGES_FILE, judge)
6007
+
6008
+
6009
+ def append_match(repo, slug, row):
6010
+ """Problems, [] exactly when the tournament row was written (`match_problems`)."""
6011
+ base, _state_, refusal = _campaign_line(repo, slug)
6012
+ if refusal:
6013
+ return [refusal]
6014
+ ideas = {r["id"]: r for r in _rows_of(base, IDEAS_FILE) if _text(r.get("id"))}
6015
+ return _append_campaign(os.path.join(base, TOURNAMENT_FILE), TOURNAMENT_FILE,
6016
+ lambda _rows: (match_problems(row, ideas), [row]))
6017
+
6018
+
6019
+ def _stage_histories(rows):
6020
+ out = {}
6021
+ for row in rows:
6022
+ if isinstance(row.get("idea"), str):
6023
+ out.setdefault(row["idea"], []).append(row)
6024
+ return out
6025
+
6026
+
6027
+ def _current(history):
6028
+ stage = history[-1].get("stage") if history else "proposed"
6029
+ return stage if stage in STAGES else "proposed"
6030
+
6031
+
6032
+ def campaign_stages(base):
6033
+ """{idea id: the stage its newest stages.jsonl row says, `proposed` with none}."""
6034
+ histories = _stage_histories(_rows_of(base, STAGES_FILE))
6035
+ return {r.get("id"): _current(histories.get(r.get("id"), []))
6036
+ for r in _rows_of(base, IDEAS_FILE) if _text(r.get("id"))}
6037
+
6038
+
6039
+ def _reached(history, stage):
6040
+ """True when an idea's history went at least as far as `stage` in the funnel
6041
+ (`duplicate` and `killed` are exits: read by presence)."""
6042
+ if stage == "proposed":
6043
+ return True
6044
+ if stage in ("duplicate", "killed"):
6045
+ return any(r.get("stage") == stage for r in history)
6046
+ return any(r.get("stage") in STAGES[:-1]
6047
+ and STAGES.index(r["stage"]) >= STAGES.index(stage) for r in history)
6048
+
6049
+
6050
+ def _tournament_warnings(ideas, matches):
6051
+ """The judging a pair or an idea still owes: a judge family that generated
6052
+ neither idea (else both generators' families), both presentation orders, and
6053
+ two judges per idea. Warnings, never refusals: the tournament is a prior."""
6054
+ pairs, judges = {}, {}
6055
+ for m in matches:
6056
+ a, b = m.get("a"), m.get("b")
6057
+ if not (_is_idea(a, ideas) and _is_idea(b, ideas)) or a == b:
6058
+ continue
6059
+ pairs.setdefault(tuple(sorted((a, b), key=_idea_number)), []).append(m)
6060
+ who = (_family(m.get("judge")), str((m.get("judge") or {}).get("model")))
6061
+ for x in (a, b):
6062
+ judges.setdefault(x, set()).add(who)
6063
+ out = []
6064
+ for (a, b), rows in sorted(pairs.items(), key=lambda p: (_idea_number(p[0][0]),
6065
+ _idea_number(p[0][1]))):
6066
+ fa, fb = _family(ideas[a].get("generator")), _family(ideas[b].get("generator"))
6067
+ families = {_family(m.get("judge")) for m in rows} - {None}
6068
+ if not families - {fa, fb} and (fa == fb or not {fa, fb} <= families):
6069
+ out.append("tournament.jsonl: %s v %s was judged only by a family that "
6070
+ "generated one of them (%s): prefer a family that generated "
6071
+ "neither, else have the other generator's family judge it too"
6072
+ % (a, b, ", ".join(sorted(families))))
6073
+ orders = {m.get("order") for m in rows}
6074
+ if len(orders & set(MATCH_ORDERS)) < 2:
6075
+ out.append("tournament.jsonl: %s v %s was judged in one order only: a "
6076
+ "position bias shows only when both orders are judged" % (a, b))
6077
+ for x in sorted(judges, key=_idea_number):
6078
+ if len(judges[x]) < 2:
6079
+ out.append("tournament.jsonl: %s has fewer than 2 judges (%d): one judge's "
6080
+ "ranking is one opinion" % (x, len(judges[x])))
6081
+ return out
6082
+
6083
+
6084
+ def _check_tested(base, histories, warnings):
6085
+ """Rules 7's disclosure: an exploratory report says how many ideas reached
6086
+ piloted and promoted, because fifty ideas tested at the 5% level yield about
6087
+ 2.5 winners by chance and a reader cannot discount what is not counted."""
6088
+ report = os.path.join(base, "to_human", "report.md")
6089
+ if not os.path.isfile(report):
6090
+ return
6091
+ lines = [line for line in _note_text(report).splitlines()]
6092
+ for stage in ("piloted", "promoted"):
6093
+ n = sum(1 for history in histories.values() if _reached(history, stage))
6094
+ number = re.compile(r"(?<![\w.])%d(?!\w|\.\d)" % n)
6095
+ if not any(stage in line.lower() and number.search(line) for line in lines):
6096
+ warnings.append("to_human/report.md does not state how many ideas reached "
6097
+ "%s (%d): an exploratory line discloses how many ideas it "
6098
+ "tested - fifty ideas tested at the 5%% level yield about "
6099
+ "2.5 winners by chance" % (stage, n))
6100
+
6101
+
6102
+ def _arena_of(text):
6103
+ try:
6104
+ state = json.loads(text)
6105
+ except ValueError:
6106
+ return None
6107
+ arena = state.get("arena") if isinstance(state, dict) else None
6108
+ return arena if isinstance(arena, dict) and _text(arena.get("problem")) \
6109
+ and _text(arena.get("budget")) else None
6110
+
6111
+
6112
+ def _arena_lock(repo, base):
6113
+ """({history: the arena lock commit or None}, the arena at the lock or None):
6114
+ the first commit along HEAD whose state.json holds an arena with a problem and
6115
+ a budget, read in every history the order rules read."""
6116
+ firsts, locked = {}, None
6117
+ for top, rel in _histories(repo, os.path.join(base, "state.json")):
6118
+ firsts[top] = None
6119
+ for sha, text in _versions(top, rel):
6120
+ arena = _arena_of(text)
6121
+ if arena is not None:
6122
+ firsts[top] = sha
6123
+ locked = locked if locked is not None else arena
6124
+ break
6125
+ return firsts, locked
6126
+
6127
+
6128
+ def _check_arena_order(repo, base, state, errors, warnings, strict):
6129
+ """ideas.jsonl and every results.jsonl have to enter the history after the arena
6130
+ lock (`_arena_lock`) - the evaluation lock's ancestry rule (`_answers`) - and
6131
+ the arena may not change after it, or a re-written `evaluator_sha256` would
6132
+ re-lock a moved evaluator."""
6133
+ firsts, locked = _arena_lock(repo, base)
6134
+ if locked is not None and state.get("arena") != locked:
6135
+ errors.append("state.json arena changed after the lock: the problem, the budget "
6136
+ "and the evaluator are fixed before the first idea - a new arena "
6137
+ "is a new line")
6138
+ targets = [(IDEAS_FILE, os.path.join(base, IDEAS_FILE))] + [
6139
+ ("experiments/%s/results.jsonl" % h,
6140
+ os.path.join(base, "experiments", h, "results.jsonl"))
6141
+ for h in _experiment_names(base)]
6142
+ for label, target in targets:
6143
+ added, err = _oldest_adds(repo, target)
6144
+ if err or not any(added.values()):
6145
+ continue
6146
+ answers = _answers(repo, firsts, added)
6147
+ if False in answers.values():
6148
+ errors.append("%s entered the history before the arena was locked: commit "
6149
+ "state.json's arena first, then the campaign's rows in later "
6150
+ "commits" % label)
6151
+ elif None in answers.values():
6152
+ _soft(errors, warnings, strict,
6153
+ "git could not order %s against the arena lock" % label)
6154
+
6155
+
6156
+ def _check_exploratory(repo, base, state, errors, warnings, strict, git):
6157
+ """Every rules-7 rule over the campaign files: each row by its row type's one
6158
+ judgement, a known prior art with no kill after it, the tournament's judging
6159
+ warnings, the report's count of ideas tested, and with git the arena lock's
6160
+ order and the three files left append-only."""
6161
+ found = {name: _campaign_rows(os.path.join(base, name), name)
6162
+ for name in CAMPAIGN_FILES}
6163
+ for name in CAMPAIGN_FILES:
6164
+ errors.extend(found[name][1])
6165
+ held = []
6166
+ for n, row in found[IDEAS_FILE][0]:
6167
+ errors.extend("%s:%d %s" % (IDEAS_FILE, n, p)
6168
+ for p in idea_problems(row, state, held))
6169
+ held.append(row)
6170
+ ideas = {r.get("id"): r for r in held if _text(r.get("id"))}
6171
+ valid = _valid_matches([row for _n, row in found[TOURNAMENT_FILE][0]], ideas)
6172
+ evaluator = evaluator_problem(repo, state)
6173
+ histories = {}
6174
+ for n, row in found[STAGES_FILE][0]:
6175
+ key = row.get("idea") if isinstance(row.get("idea"), str) else None
6176
+ history = histories.setdefault(key, [])
6177
+ errors.extend("%s:%d %s" % (STAGES_FILE, n, p) for p in stage_problems(
6178
+ row, base, repo, state, ideas, list(history), valid, evaluator, git))
6179
+ history.append(row)
6180
+ for iid in sorted((i for i in histories if isinstance(i, str)), key=_idea_number):
6181
+ last = histories[iid][-1]
6182
+ if last.get("stage") == "prior-art" and _verdict(last) == "known":
6183
+ errors.append("%s: %s's prior art is known and was not followed by killed: "
6184
+ "a known idea is killed in the same append (`stage ... "
6185
+ "prior-art --reason`)" % (STAGES_FILE, iid))
6186
+ for n, row in found[TOURNAMENT_FILE][0]:
6187
+ errors.extend("%s:%d %s" % (TOURNAMENT_FILE, n, p)
6188
+ for p in match_problems(row, ideas))
6189
+ warnings.extend(_tournament_warnings(ideas, valid))
6190
+ _check_tested(base, histories, warnings)
6191
+ if git:
6192
+ _check_arena_order(repo, base, state, errors, warnings, strict)
6193
+ for name in CAMPAIGN_FILES:
6194
+ path = os.path.join(base, name)
6195
+ if os.path.isfile(path):
6196
+ _check_append_only(repo, name, path, errors, warnings, True)
6197
+
6198
+
6199
+ def bradley_terry(ids, matches):
6200
+ """{idea id: log strength} from the tournament rows, by the MM algorithm
6201
+ (Hunter 2004). Each idea also holds one virtual tie against a fixed reference of
6202
+ strength 1, so an idea that never won keeps a finite score. A tie is half a win
6203
+ to each side. The counts are summed before any iteration and every sum runs in
6204
+ id order, so the fit is the same under any order of the rows."""
6205
+ ids = sorted(set(i for i in ids if _text(i)), key=_idea_number)
6206
+ wins = {i: 0.5 for i in ids}
6207
+ games = {}
6208
+ for m in matches:
6209
+ a, b, winner = m.get("a"), m.get("b"), m.get("winner")
6210
+ if not (isinstance(a, str) and isinstance(b, str)) or a not in wins \
6211
+ or b not in wins or a == b or winner not in MATCH_WINNERS:
6212
+ continue
6213
+ for x, y in ((a, b), (b, a)):
6214
+ games.setdefault(x, {})
6215
+ games[x][y] = games[x].get(y, 0) + 1
6216
+ if winner == "tie":
6217
+ wins[a] += 0.5
6218
+ wins[b] += 0.5
6219
+ else:
6220
+ wins[a if winner == "a" else b] += 1
6221
+ opponents = {i: sorted(games.get(i, {}).items(), key=lambda kv: _idea_number(kv[0]))
6222
+ for i in ids}
6223
+ strength = {i: 1.0 for i in ids}
6224
+ for _round in range(10000):
6225
+ new = {i: wins[i] / (1.0 / (strength[i] + 1.0) + sum(
6226
+ n / (strength[i] + strength[j]) for j, n in opponents[i])) for i in ids}
6227
+ moved = max((abs(math.log(new[i] / strength[i])) for i in ids), default=0.0)
6228
+ strength = new
6229
+ if moved < 1e-12:
6230
+ break
6231
+ return {i: round(math.log(strength[i]), 6) for i in ids}
6232
+
6233
+
6234
+ def _next_action(slug, iid, stage, history, record, arena):
6235
+ """The one action an idea is owed next, as the command that does it."""
6236
+ cmd = "tezgah-research stage %s %s" % (slug, iid)
6237
+ if stage == "proposed":
6238
+ return ("dedup (`tezgah-research dedup %s`), then `%s prior-art --evidence "
6239
+ "'{\"verdict\": \"novel|incremental|known\", \"nearest\": [3 indexed "
6240
+ "notes], \"delta\": \"<one sentence>\"}'`" % (slug, cmd))
6241
+ if stage == "duplicate":
6242
+ evidence = history[-1].get("evidence")
6243
+ return "`%s killed --reason \"duplicate of %s\"`" % (
6244
+ cmd, evidence.get("of", "?") if isinstance(evidence, dict) else "?")
6245
+ if stage == "prior-art" and _verdict(history[-1]) == "known":
6246
+ return ("`%s killed --reason \"<what already does it>\"` - its prior art is "
6247
+ "known" % cmd)
6248
+ if stage == "prior-art":
6249
+ if record["judges"] >= 2 and record["orders"] >= 2:
6250
+ return "`%s screened --evidence tournament.jsonl`" % cmd
6251
+ return ("judge it: `tezgah-research match %s` (2+ judges of a family that "
6252
+ "generated neither idea, both orders)" % slug)
6253
+ if stage == "screened":
6254
+ return ("pilot: commit experiments/<h>/protocol.md naming %s and its kill "
6255
+ "criterion, run it, then `%s piloted --evidence <h>`" % (iid, cmd))
6256
+ if stage == "piloted":
6257
+ return ("full run under the arena's evaluator, then `%s promoted --evidence "
6258
+ "<h>`, or `%s killed --reason \"...\"`" % (cmd, cmd))
6259
+ if stage == "promoted":
6260
+ if not _text(arena.get("evaluator")):
6261
+ return "none: the arena has no evaluator - judged by review, never confirmed"
6262
+ return ("confirm on a held-out set nothing looked at: log the look in "
6263
+ "held_out_log.jsonl, run it, then `%s confirmed --evidence "
6264
+ "'{\"experiment\": \"<h>\", \"set\": \"<set>\"}'`" % cmd)
6265
+ if stage == "confirmed":
6266
+ return "none: confirmed"
6267
+ return "none: killed (%s)" % (history[-1].get("reason") if history else "")
6268
+
6269
+
6270
+ def board(repo, slug):
6271
+ """The campaign as files show it: the arena, each file's rows and torn tail,
6272
+ every idea's stage, Bradley-Terry score, match record and next action, the
6273
+ number of ideas tested, and the campaign's next step. A report a fresh session
6274
+ resumes from - nothing in it is stored anywhere else - and never a gate."""
6275
+ base = line_dir(repo, slug)
6276
+ state = _state(base)
6277
+ arena = state.get("arena") if isinstance(state.get("arena"), dict) else {}
6278
+ files, rows = {}, {}
6279
+ for name in CAMPAIGN_FILES:
6280
+ found, problems, fragment = _campaign_rows(os.path.join(base, name), name)
6281
+ rows[name] = [row for _n, row in found]
6282
+ files[name] = {"rows": len(found), "fragment": fragment, "problems": problems}
6283
+ ideas = {r["id"]: r for r in rows[IDEAS_FILE] if _text(r.get("id"))}
6284
+ histories = _stage_histories(rows[STAGES_FILE])
6285
+ matches = _valid_matches(rows[TOURNAMENT_FILE], ideas)
6286
+ scores = bradley_terry(list(ideas), matches)
6287
+ entries = []
6288
+ for iid, idea in ideas.items():
6289
+ mine = [m for m in matches if iid in (m.get("a"), m.get("b"))]
6290
+ side = lambda m: "a" if m.get("a") == iid else "b" # noqa: E731
6291
+ record = {"matches": len(mine),
6292
+ "wins": sum(1 for m in mine if m.get("winner") == side(m)),
6293
+ "ties": sum(1 for m in mine if m.get("winner") == "tie"),
6294
+ "judges": len({(_family(m.get("judge")),
6295
+ str((m.get("judge") or {}).get("model"))) for m in mine}),
6296
+ "orders": len({m.get("order") for m in mine} & set(MATCH_ORDERS))}
6297
+ history = histories.get(iid, [])
6298
+ stage = _current(history)
6299
+ entries.append(dict(record, id=iid, stage=stage, score=scores.get(iid, 0.0),
6300
+ operator=idea.get("operator"),
6301
+ family=_family(idea.get("generator")),
6302
+ next=_next_action(slug, iid, stage, history, record, arena)))
6303
+ entries.sort(key=lambda e: (-e["score"], _idea_number(e["id"])))
6304
+ for rank, entry in enumerate(entries, 1):
6305
+ entry["rank"] = rank
6306
+ counts = {s: sum(1 for e in entries if e["stage"] == s) for s in STAGES}
6307
+ tested = {s: sum(1 for i in ideas if _reached(histories.get(i, []), s))
6308
+ for s in ("piloted", "promoted", "confirmed")}
6309
+ resume = ["%s ends in a %d-byte fragment a killed writer left: the next append "
6310
+ "drops it, so re-run the append that crashed" % (name, files[name]["fragment"])
6311
+ for name in CAMPAIGN_FILES if files[name]["fragment"]]
6312
+ live = [e for e in entries if e["stage"] not in ("confirmed", "killed")]
6313
+ live.sort(key=lambda e: (-STAGES.index(e["stage"]), -e["score"], _idea_number(e["id"])))
6314
+ nxt = ("%s - %s" % (live[0]["id"], live[0]["next"]) if live else
6315
+ ("record ideas: `tezgah-research idea %s`" % slug) if not ideas else
6316
+ "nothing left: every idea is confirmed or killed - write the report")
6317
+ evaluator = ("none (review only)" if not _text(arena.get("evaluator")) else
6318
+ "%s %s" % (arena["evaluator"], "changed" if evaluator_problem(repo, state)
6319
+ else "locked"))
6320
+ return {"slug": slug, "arena": arena, "evaluator": evaluator, "files": files,
6321
+ "stages": counts, "ideas": entries, "tested": tested, "resume": resume,
6322
+ "next": nxt}
6323
+
6324
+
6325
+ def board_text(report):
6326
+ """The lines `tezgah-research board` prints, from `board`'s report."""
6327
+ arena = report["arena"]
6328
+ out = ["board %s: %s" % (report["slug"], arena.get("problem") or "(no arena)"),
6329
+ "budget: %s | evaluator: %s | metric: %s"
6330
+ % (arena.get("budget") or "-", report["evaluator"], arena.get("metric") or "-"),
6331
+ "files: " + ", ".join("%s %d row(s)" % (name, f["rows"])
6332
+ for name, f in report["files"].items())]
6333
+ out.extend("resume: %s" % line for line in report["resume"])
6334
+ out.extend("problem: %s" % p for f in report["files"].values() for p in f["problems"])
6335
+ out.append("stages: " + ", ".join("%s %d" % (s, n) for s, n in report["stages"].items()))
6336
+ out.append("%-4s %-4s %-10s %8s %-9s %-18s %-8s %s"
6337
+ % ("rank", "id", "stage", "score", "w-t-n", "operator", "family", "next"))
6338
+ for e in report["ideas"]:
6339
+ out.append("%-4d %-4s %-10s %+8.3f %-9s %-18s %-8s %s" % (
6340
+ e["rank"], e["id"], e["stage"], e["score"],
6341
+ "%d-%d-%d" % (e["wins"], e["ties"], e["matches"]), e["operator"] or "-",
6342
+ e["family"] or "-", e["next"]))
6343
+ tested = report["tested"]
6344
+ out.append("tested: %d piloted, %d promoted, %d confirmed of %d idea(s) - the "
6345
+ "score is a prior from judged matches, never a verdict"
6346
+ % (tested["piloted"], tested["promoted"], tested["confirmed"],
6347
+ len(report["ideas"])))
6348
+ out.append("next: %s" % report["next"])
6349
+ return out
6350
+
6351
+
6352
+ def _embed_backend():
6353
+ """("embed:<model>", reader) when the opt-in static embedding is selected and
6354
+ pinned (`tezgah_embed.reader`), else None: the dedup then ranks by words."""
6355
+ try:
6356
+ import tezgah_apps
6357
+ import tezgah_embed
6358
+ model = tezgah_embed.reader()
6359
+ if model is None:
6360
+ return None
6361
+ ident = next((i for i in tezgah_apps.selected_ids(tp.config().get("features"))
6362
+ if i in tezgah_embed.MODELS and tezgah_embed.valid(i)), "model")
6363
+ return "embed:%s" % ident, model
6364
+ except Exception: # fail-open: BM25 below
6365
+ return None
6366
+
6367
+
6368
+ def similarity_matrix(texts):
6369
+ """(backend, matrix): every pair's similarity in 0..1, 1.0 on the diagonal. The
6370
+ cosine of the static embedding when it is selected and pinned; else BM25 read
6371
+ as a similarity - the mean of each text's score for the other over its score for
6372
+ itself, over the corpus `texts` (`tezgah_rank.scores`)."""
6373
+ n = len(texts)
6374
+ found = _embed_backend()
6375
+ if found is not None:
6376
+ name, model = found
6377
+ try:
6378
+ import tezgah_embed
6379
+ vecs = [model.embed(t) for t in texts]
6380
+ return name, [[1.0 if i == j else max(0.0, min(1.0, tezgah_embed.cosine(
6381
+ vecs[i], vecs[j]))) for j in range(n)] for i in range(n)]
6382
+ except Exception: # fail-open: BM25 below
6383
+ pass
6384
+ import tezgah_rank
6385
+ raw = [tezgah_rank.scores(t, texts) for t in texts]
6386
+ rel = [[raw[i][j] / raw[i][i] if raw[i][i] > 0 else 0.0 for j in range(n)]
6387
+ for i in range(n)]
6388
+ return "bm25", [[1.0 if i == j else max(0.0, min(1.0, (rel[i][j] + rel[j][i]) / 2))
6389
+ for j in range(n)] for i in range(n)]
6390
+
6391
+
6392
+ def dedup(repo, slug, threshold=None):
6393
+ """The proposed ideas clustered by statement: single linkage at `threshold`
6394
+ (None: the backend's calibrated one, `DEDUP_THRESHOLDS`, else Si et al.'s), each
6395
+ cluster's representative its lowest id and every member's similarity to it.
6396
+ Writes nothing; the report names the backend and where its threshold came from."""
6397
+ base = line_dir(repo, slug)
6398
+ stages = campaign_stages(base)
6399
+ live = sorted((r for r in _rows_of(base, IDEAS_FILE)
6400
+ if _text(r.get("id")) and stages.get(r["id"]) == "proposed"),
6401
+ key=lambda r: _idea_number(r["id"]))
6402
+ backend, matrix = similarity_matrix([str(r.get("statement") or "") for r in live])
6403
+ source = "--threshold"
6404
+ if threshold is None:
6405
+ threshold = DEDUP_THRESHOLDS.get(backend, DEDUP_THRESHOLD)
6406
+ source = ("calibrated for %s on 40 fixture and 400 MRPC pairs" % backend
6407
+ if backend in DEDUP_THRESHOLDS
6408
+ else "uncalibrated: Si et al.'s cosine 0.8 on MiniLM")
6409
+ parent = list(range(len(live)))
6410
+
6411
+ def find(i):
6412
+ while parent[i] != i:
6413
+ i = parent[i]
6414
+ return i
6415
+
6416
+ for i in range(len(live)):
6417
+ for j in range(i + 1, len(live)):
6418
+ if matrix[i][j] >= threshold:
6419
+ parent[max(find(i), find(j))] = min(find(i), find(j))
6420
+ groups = {}
6421
+ for i in range(len(live)):
6422
+ groups.setdefault(find(i), []).append(i)
6423
+ clusters = [{"members": [live[i]["id"] for i in members],
6424
+ "similarity": {live[i]["id"]: round(matrix[members[0]][i], 4)
6425
+ for i in members}}
6426
+ for _root, members in sorted(groups.items())]
6427
+ return {"backend": backend, "threshold": threshold, "threshold_source": source,
6428
+ "ideas": len(live), "clusters": clusters}
6429
+
6430
+
6431
+ def dedup_text(report, slug):
6432
+ out = ["dedup %s: %d proposed idea(s), backend: %s, threshold %.2f (%s)"
6433
+ % (slug, report["ideas"], report["backend"], report["threshold"],
6434
+ report["threshold_source"])]
6435
+ for c in report["clusters"]:
6436
+ if len(c["members"]) < 2:
6437
+ continue
6438
+ rep = c["members"][0]
6439
+ out.append("cluster %s: %s" % (rep, ", ".join(
6440
+ "%s (%.2f)" % (m, c["similarity"][m]) for m in c["members"][1:])))
6441
+ out.extend(" tezgah-research stage %s %s duplicate --evidence '%s'"
6442
+ % (slug, m, json.dumps({"of": rep, "similarity": c["similarity"][m],
6443
+ "backend": report["backend"]}))
6444
+ for m in c["members"][1:])
6445
+ singles = sum(1 for c in report["clusters"] if len(c["members"]) == 1)
6446
+ out.append("%d idea(s) stand alone; nothing was written" % singles)
6447
+ return out
6448
+
6449
+
6450
+ def _ranks(values):
6451
+ order = sorted(range(len(values)), key=lambda i: values[i])
6452
+ ranks = [0.0] * len(values)
6453
+ i = 0
6454
+ while i < len(order):
6455
+ j = i
6456
+ while j + 1 < len(order) and values[order[j + 1]] == values[order[i]]:
6457
+ j += 1
6458
+ for k in range(i, j + 1):
6459
+ ranks[order[k]] = (i + j) / 2.0 + 1
6460
+ i = j + 1
6461
+ return ranks
6462
+
6463
+
6464
+ def spearman(pairs):
6465
+ """Spearman's rho over (x, y) pairs with average ranks for ties; None below 3
6466
+ pairs or when either side does not vary."""
6467
+ if len(pairs) < 3:
6468
+ return None
6469
+ rx, ry = _ranks([p[0] for p in pairs]), _ranks([p[1] for p in pairs])
6470
+ mx, my = sum(rx) / len(rx), sum(ry) / len(ry)
6471
+ sxy = sum((a - mx) * (b - my) for a, b in zip(rx, ry))
6472
+ sxx = sum((a - mx) ** 2 for a in rx)
6473
+ syy = sum((b - my) ** 2 for b in ry)
6474
+ return round(sxy / math.sqrt(sxx * syy), 4) if sxx and syy else None
6475
+
6476
+
6477
+ def _pilot_outcome(history):
6478
+ """An idea's result once piloted: 2 confirmed, 1 promoted, 0 killed after its
6479
+ pilot; None before a pilot or while it is still at piloted."""
6480
+ if not _reached(history, "piloted"):
6481
+ return None
6482
+ if _reached(history, "confirmed"):
6483
+ return 2
6484
+ if _reached(history, "promoted"):
6485
+ return 1
6486
+ return 0 if _reached(history, "killed") else None
6487
+
6488
+
6489
+ def operator_weights(operators):
6490
+ """The next campaign's draw per operator: uniform while nothing was promoted,
6491
+ then EXPLORE_SHARE spread evenly and the rest in proportion to each operator's
6492
+ promoted / proposed. `user` is not a move a session draws."""
6493
+ moves = [op for op in OPERATORS if op != "user"]
6494
+ rates = {op: (operators.get(op, {}).get("promoted", 0)
6495
+ / operators[op]["proposed"]) if operators.get(op, {}).get("proposed")
6496
+ else 0.0 for op in moves}
6497
+ total = sum(rates.values())
6498
+ if not total:
6499
+ return {op: round(1.0 / len(moves), 4) for op in moves}
6500
+ return {op: round(EXPLORE_SHARE / len(moves) + (1 - EXPLORE_SHARE) * rates[op] / total,
6501
+ 4) for op in moves}
6502
+
6503
+
6504
+ def yield_report(repo, all_lines=False):
6505
+ """Per operator and per generator family, how many ideas reached each stage;
6506
+ the kill reasons; Spearman's rho between the board score and the pilot outcome;
6507
+ and the next campaign's operator weights. The open exploratory lines, or every
6508
+ one with `all_lines`. A report, never a gate."""
6509
+ names = [s for s in slugs(repo) if (all_lines or not sealed(repo, s))
6510
+ and _exploratory(line_state(repo, s))]
6511
+ zero = {s: 0 for s in STAGES}
6512
+ operators, families, reasons, pairs, total = {}, {}, {}, [], 0
6513
+ for slug in names:
6514
+ base = line_dir(repo, slug)
6515
+ ideas = {r["id"]: r for r in _rows_of(base, IDEAS_FILE) if _text(r.get("id"))}
6516
+ histories = _stage_histories(_rows_of(base, STAGES_FILE))
6517
+ scores = bradley_terry(list(ideas), _valid_matches(
6518
+ _rows_of(base, TOURNAMENT_FILE), ideas))
6519
+ for iid, idea in sorted(ideas.items(), key=lambda kv: _idea_number(kv[0])):
6520
+ total += 1
6521
+ history = histories.get(iid, [])
6522
+ for key, table in ((str(idea.get("operator")), operators),
6523
+ (_family(idea.get("generator")) or "(none)", families)):
6524
+ bucket = table.setdefault(key, dict(zero))
6525
+ for stage in STAGES:
6526
+ bucket[stage] += 1 if _reached(history, stage) else 0
6527
+ kills = [r for r in history if r.get("stage") == "killed"]
6528
+ if kills:
6529
+ reason = str(kills[-1].get("reason") or "(none)").strip()
6530
+ reasons[reason] = reasons.get(reason, 0) + 1
6531
+ outcome = _pilot_outcome(history)
6532
+ if outcome is not None:
6533
+ pairs.append((scores.get(iid, 0.0), outcome))
6534
+ return {"lines": names, "ideas": total, "operators": operators, "families": families,
6535
+ "kill_reasons": reasons,
6536
+ "spearman": {"n": len(pairs), "rho": spearman(pairs)},
6537
+ "weights": operator_weights(operators)}
6538
+
6539
+
6540
+ def yield_text(report):
6541
+ out = ["yield: %d exploratory line(s)%s, %d idea(s)"
6542
+ % (len(report["lines"]), " (%s)" % ", ".join(report["lines"])
6543
+ if report["lines"] else "", report["ideas"])]
6544
+ head = "%-20s" + "".join(" %9s" % s for s in STAGES)
6545
+ for title, table in (("operator", report["operators"]),
6546
+ ("generator family", report["families"])):
6547
+ out.append(head % title)
6548
+ for key in sorted(table):
6549
+ out.append("%-20s" % key + "".join(" %9d" % table[key][s] for s in STAGES))
6550
+ if report["kill_reasons"]:
6551
+ out.append("kill reasons: " + "; ".join(
6552
+ "%dx %s" % (n, r) for r, n in sorted(report["kill_reasons"].items(),
6553
+ key=lambda kv: (-kv[1], kv[0]))))
6554
+ rho = report["spearman"]
6555
+ out.append("board score vs pilot outcome: n=%d, Spearman rho %s"
6556
+ % (rho["n"], "n too small or constant (needs 3 that vary)"
6557
+ if rho["rho"] is None else "%+.3f" % rho["rho"]))
6558
+ out.append("next-campaign operator weights (%d%% kept uniform for exploration): %s"
6559
+ % (EXPLORE_SHARE * 100, ", ".join("%s %.3f" % kv
6560
+ for kv in report["weights"].items())))
6561
+ return out