@r1z4x/tezgah 1.6.0 → 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.
- package/CHANGELOG.md +80 -1
- package/MANIFEST +5 -0
- package/README.md +6 -3
- package/bin/tezgah-docs +18 -16
- package/bin/tezgah-doctor +152 -0
- package/bin/tezgah-research +234 -10
- package/bin/tezgah-route +48 -1
- package/bin/tezgah-setup +26 -13
- package/bin/tezgah-task +589 -17
- package/bin/tezgah-taste +48 -10
- package/docs/architecture.md +7 -4
- package/docs/clarity-baseline.json +2 -2
- package/docs/contract.md +108 -24
- package/docs/evidence.md +201 -28
- package/docs/feature-audit.md +1 -1
- package/docs/gate.md +60 -22
- package/docs/glossary.md +4 -4
- package/docs/hosts.md +8 -8
- package/docs/index.json +11 -5
- package/docs/judge.md +9 -3
- package/docs/models.md +35 -2
- package/docs/operations.md +103 -3
- package/docs/research.md +346 -13
- package/docs/skills.md +1 -1
- package/docs/status-line.md +5 -5
- package/docs/testing.md +2 -2
- package/docs/vision.md +12 -4
- package/hooks/hooks.json +2 -2
- package/hooks/lang/en.json +323 -0
- package/hooks/lang/tr.json +344 -0
- package/hooks/projects-posttooluse.py +24 -5
- package/hooks/projects-pretooluse.py +9 -4
- package/hooks/projects-stop.py +28 -16
- package/hooks/tezgah_agents.py +29 -10
- package/hooks/tezgah_clarity.py +7 -3
- package/hooks/tezgah_context.py +425 -406
- package/hooks/tezgah_gate.py +583 -33
- package/hooks/tezgah_integrity.py +1480 -197
- package/hooks/tezgah_models.py +337 -15
- package/hooks/tezgah_paths.py +103 -10
- package/hooks/tezgah_policy.py +258 -126
- package/hooks/tezgah_rank.py +29 -27
- package/hooks/tezgah_replay.py +200 -30
- package/hooks/tezgah_research.py +1294 -9
- package/hooks/tezgah_stopspec.py +7 -0
- package/hooks/tezgah_store.py +74 -27
- package/hooks/tezgah_task.py +915 -6
- package/hosts/codex/hook.py +9 -10
- package/hosts/cursor/hook.py +21 -14
- package/hosts/dsh/hooks.json +2 -2
- package/hosts/omp/hook.py +44 -23
- package/hosts/omp/tezgah-hook.ts.in +93 -7
- package/hosts/opencode/plugins/tezgah.js +102 -2
- package/output-styles/tezgah.md +76 -42
- package/package.json +1 -1
- package/skills/i-have-adhd/EVALS.md +22 -2
- package/skills/i-have-adhd/SKILL.md +12 -5
- package/skills/plan-add/SKILL.md +5 -3
- package/skills/plan-sync/SKILL.md +5 -3
- package/skills/ponytail/EVALS.md +21 -2
- package/skills/ponytail/SKILL.md +4 -4
- package/skills/research/SKILL.md +89 -1
- package/skills/tezgah-contract/EVALS.md +52 -7
- package/skills/tezgah-contract/SKILL.md +151 -56
package/hooks/tezgah_research.py
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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):
|
|
@@ -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
|