@r1z4x/tezgah 1.5.1 → 1.7.0

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