agentforge-framework 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentforge_framework/.claude-plugin/plugin.json +4 -0
- agentforge_framework/__init__.py +3 -0
- agentforge_framework/agents/__init__.py +92 -0
- agentforge_framework/agents/architect.py +146 -0
- agentforge_framework/agents/implementer.py +162 -0
- agentforge_framework/agents/orchestrator.py +588 -0
- agentforge_framework/agents/reviewer.py +335 -0
- agentforge_framework/agents/security.py +138 -0
- agentforge_framework/agents/tester.py +125 -0
- agentforge_framework/cli.py +461 -0
- agentforge_framework/context/__init__.py +1 -0
- agentforge_framework/context/extractors/__init__.py +76 -0
- agentforge_framework/context/extractors/base.py +47 -0
- agentforge_framework/context/extractors/python.py +65 -0
- agentforge_framework/context/extractors/sql.py +121 -0
- agentforge_framework/context/extractors/yaml.py +59 -0
- agentforge_framework/context/prompt.py +104 -0
- agentforge_framework/context/resolver.py +185 -0
- agentforge_framework/core/__init__.py +1 -0
- agentforge_framework/core/commands.py +170 -0
- agentforge_framework/core/config.py +90 -0
- agentforge_framework/core/contracts.py +875 -0
- agentforge_framework/core/gates.py +333 -0
- agentforge_framework/core/issues.py +697 -0
- agentforge_framework/core/plan_format.py +272 -0
- agentforge_framework/core/process.py +141 -0
- agentforge_framework/core/project.py +262 -0
- agentforge_framework/core/registry.py +455 -0
- agentforge_framework/core/repo.py +185 -0
- agentforge_framework/core/router.py +1 -0
- agentforge_framework/core/runtime.py +639 -0
- agentforge_framework/core/skills.py +255 -0
- agentforge_framework/core/workflow.py +215 -0
- agentforge_framework/plugins/__init__.py +35 -0
- agentforge_framework/plugins/databricks/__init__.py +86 -0
- agentforge_framework/plugins/pyspark/__init__.py +57 -0
- agentforge_framework/plugins/python/__init__.py +45 -0
- agentforge_framework/plugins/sql/__init__.py +377 -0
- agentforge_framework/providers/__init__.py +48 -0
- agentforge_framework/providers/base.py +248 -0
- agentforge_framework/providers/claude.py +159 -0
- agentforge_framework/providers/codex.py +139 -0
- agentforge_framework/skills/MANIFEST.yaml +157 -0
- agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework/skills/domain-modeling/ADR-FORMAT.md +47 -0
- agentforge_framework/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- agentforge_framework/skills/domain-modeling/SKILL.md +74 -0
- agentforge_framework/skills/domain-modeling/agents/openai.yaml +3 -0
- agentforge_framework/skills/grill-with-docs/SKILL.md +76 -0
- agentforge_framework/skills/grilling/SKILL.md +28 -0
- agentforge_framework/skills/grilling/agents/openai.yaml +3 -0
- agentforge_framework/skills/to-spec/SKILL.md +75 -0
- agentforge_framework/skills/to-spec/agents/openai.yaml +5 -0
- agentforge_framework/skills/to-tickets/SKILL.md +105 -0
- agentforge_framework/skills/to-tickets/agents/openai.yaml +5 -0
- agentforge_framework/skills/unslop/SKILL.md +131 -0
- agentforge_framework/skills/unslop/evals/fixtures/silhouette/human_reference.json +66 -0
- agentforge_framework/skills/unslop/scripts/_lang.py +106 -0
- agentforge_framework/skills/unslop/scripts/banned_phrase_scan.py +784 -0
- agentforge_framework/skills/unslop/scripts/calibrate_pairs.py +580 -0
- agentforge_framework/skills/unslop/scripts/calibrate_score.py +273 -0
- agentforge_framework/skills/unslop/scripts/check_packs.py +80 -0
- agentforge_framework/skills/unslop/scripts/check_suggestions.py +225 -0
- agentforge_framework/skills/unslop/scripts/contribute.py +373 -0
- agentforge_framework/skills/unslop/scripts/diff_check.py +139 -0
- agentforge_framework/skills/unslop/scripts/extract_constraints.py +201 -0
- agentforge_framework/skills/unslop/scripts/harvest_classify.py +223 -0
- agentforge_framework/skills/unslop/scripts/harvest_samples.py +534 -0
- agentforge_framework/skills/unslop/scripts/readability_metrics.py +295 -0
- agentforge_framework/skills/unslop/scripts/refresh_status.py +154 -0
- agentforge_framework/skills/unslop/scripts/silhouette_scan.py +390 -0
- agentforge_framework/skills/unslop/scripts/structure_scan.py +322 -0
- agentforge_framework/skills/unslop/scripts/suggest.py +211 -0
- agentforge_framework/skills/unslop/scripts/validate_preservation.py +409 -0
- agentforge_framework/skills/unslop/scripts/voice_card.py +496 -0
- agentforge_framework/skills/unslop/scripts/voice_profile.py +194 -0
- agentforge_framework/skills/unslop/scripts/voice_score.py +271 -0
- agentforge_framework/skills/unslop/scripts/wiki_sync.py +479 -0
- agentforge_framework/skills/write-plainly/SKILL.md +94 -0
- agentforge_framework/workflows/bugfix.yaml +8 -0
- agentforge_framework/workflows/feature.yaml +16 -0
- agentforge_framework/workflows/review.yaml +10 -0
- agentforge_framework-0.2.0.dist-info/METADATA +321 -0
- agentforge_framework-0.2.0.dist-info/RECORD +89 -0
- agentforge_framework-0.2.0.dist-info/WHEEL +5 -0
- agentforge_framework-0.2.0.dist-info/entry_points.txt +3 -0
- agentforge_framework-0.2.0.dist-info/licenses/LICENSE +202 -0
- agentforge_framework-0.2.0.dist-info/licenses/src/agentforge_framework/skills/NOTICE +49 -0
- agentforge_framework-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Aggregate a teach-calibration preferences JSONL into per-dimension confidence,
|
|
4
|
+
surface conflicts against a measured stylometric profile, and pick the next
|
|
5
|
+
dimension to play.
|
|
6
|
+
|
|
7
|
+
Preferences JSONL rows (one per game round):
|
|
8
|
+
{"pair_id": "...", "dimension": "contractions", "choice": "a"|"b"|"neither",
|
|
9
|
+
"ts": "...", "a_label": "contracted", "b_label": "expanded"}
|
|
10
|
+
|
|
11
|
+
`a_label`/`b_label` are optional. When present (calibrate_pairs.py's
|
|
12
|
+
transform_applied names the B pole; A sits at the other pole in the same
|
|
13
|
+
dimension's POLES pair) they let this script report a semantic preferred
|
|
14
|
+
DIRECTION ("expanded", "short", "formal", ...) instead of the bare literal
|
|
15
|
+
"a"/"b" tally, which is what --profile conflict detection needs. Rows without
|
|
16
|
+
labels still count toward n and the confidence interval; they just can't
|
|
17
|
+
resolve to a named pole (direction is reported as "a" or "b").
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
python3 calibrate_score.py --preferences prefs.jsonl
|
|
21
|
+
python3 calibrate_score.py --preferences prefs.jsonl --profile profile.json
|
|
22
|
+
python3 calibrate_score.py --preferences prefs.jsonl --next
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import math
|
|
29
|
+
import sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
HERE = Path(__file__).resolve().parent
|
|
33
|
+
sys.path.insert(0, str(HERE))
|
|
34
|
+
|
|
35
|
+
from calibrate_pairs import DIMENSIONS, POLES # noqa: E402
|
|
36
|
+
|
|
37
|
+
MIN_K = 5
|
|
38
|
+
Z = 1.96
|
|
39
|
+
|
|
40
|
+
# Dimension -> (profile_key, pole_meaning) used only for --profile conflict
|
|
41
|
+
# detection. `low_pole`/`high_pole` say which named pole a LOW vs HIGH measured
|
|
42
|
+
# value corresponds to, so a confident stated preference for the opposite pole
|
|
43
|
+
# of what the measured value implies is flagged.
|
|
44
|
+
_PROFILE_LINKS: dict[str, dict] = {
|
|
45
|
+
"contractions": {
|
|
46
|
+
"profile_key": "contraction_rate",
|
|
47
|
+
"low_pole": "expanded",
|
|
48
|
+
"high_pole": "contracted",
|
|
49
|
+
"low_threshold": 0.05,
|
|
50
|
+
"high_threshold": 0.20,
|
|
51
|
+
},
|
|
52
|
+
"sentence_length": {
|
|
53
|
+
"profile_key": "avg_sentence_length",
|
|
54
|
+
"low_pole": "short",
|
|
55
|
+
"high_pole": "long",
|
|
56
|
+
"low_threshold": 12.0,
|
|
57
|
+
"high_threshold": 20.0,
|
|
58
|
+
},
|
|
59
|
+
"staccato": {
|
|
60
|
+
"profile_key": "avg_sentence_length",
|
|
61
|
+
"low_pole": "staccato",
|
|
62
|
+
"high_pole": "flowing",
|
|
63
|
+
"low_threshold": 12.0,
|
|
64
|
+
"high_threshold": 20.0,
|
|
65
|
+
},
|
|
66
|
+
"em_dash": {
|
|
67
|
+
"profile_key": "em_dash_rate",
|
|
68
|
+
"low_pole": "plain",
|
|
69
|
+
"high_pole": "dashed",
|
|
70
|
+
"low_threshold": 0.02,
|
|
71
|
+
"high_threshold": 0.15,
|
|
72
|
+
},
|
|
73
|
+
"connectives": {
|
|
74
|
+
"profile_key": "formal_connective_rate",
|
|
75
|
+
"low_pole": "plain",
|
|
76
|
+
"high_pole": "formal",
|
|
77
|
+
"low_threshold": 0.10,
|
|
78
|
+
"high_threshold": 0.40,
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
CONFIDENCE_STOP = 0.7
|
|
83
|
+
K_STOP = 9
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def wilson_lower_bound(successes: int, n: int, z: float = Z) -> float:
|
|
87
|
+
if n == 0:
|
|
88
|
+
return 0.0
|
|
89
|
+
phat = successes / n
|
|
90
|
+
denom = 1 + z * z / n
|
|
91
|
+
center = phat + z * z / (2 * n)
|
|
92
|
+
margin = z * math.sqrt((phat * (1 - phat) + z * z / (4 * n)) / n)
|
|
93
|
+
return round(max(0.0, (center - margin) / denom), 3)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_preferences(path: Path) -> list[dict]:
|
|
97
|
+
rows = []
|
|
98
|
+
for line in path.read_text().splitlines():
|
|
99
|
+
line = line.strip()
|
|
100
|
+
if line:
|
|
101
|
+
rows.append(json.loads(line))
|
|
102
|
+
return rows
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def dedup_by_pair_id(rows: list[dict]) -> list[dict]:
|
|
106
|
+
"""Keep one row per `pair_id`: the LATEST by `ts` (ISO 8601, so a plain
|
|
107
|
+
string comparison orders correctly). A game round replayed twice (e.g. the
|
|
108
|
+
agent crashed mid-write and re-ran the round) must count once toward `n`
|
|
109
|
+
and resolve to whatever the user most recently chose, not double-count or
|
|
110
|
+
let an earlier write win. Rows without a `pair_id` can't be deduped
|
|
111
|
+
against anything, so each is kept as-is.
|
|
112
|
+
"""
|
|
113
|
+
by_pair_id: dict[str, dict] = {}
|
|
114
|
+
passthrough: list[dict] = []
|
|
115
|
+
for row in rows:
|
|
116
|
+
pid = row.get("pair_id")
|
|
117
|
+
if not pid:
|
|
118
|
+
passthrough.append(row)
|
|
119
|
+
continue
|
|
120
|
+
existing = by_pair_id.get(pid)
|
|
121
|
+
if existing is None or row.get("ts", "") >= existing.get("ts", ""):
|
|
122
|
+
by_pair_id[pid] = row
|
|
123
|
+
return list(by_pair_id.values()) + passthrough
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def aggregate(rows: list[dict]) -> dict[str, dict]:
|
|
127
|
+
rows = dedup_by_pair_id(rows)
|
|
128
|
+
result = {dim: {"n": 0, "tally": {}, "neither": 0} for dim in DIMENSIONS}
|
|
129
|
+
|
|
130
|
+
for row in rows:
|
|
131
|
+
dim = row.get("dimension")
|
|
132
|
+
if dim not in result:
|
|
133
|
+
continue
|
|
134
|
+
result[dim]["n"] += 1
|
|
135
|
+
choice = row.get("choice")
|
|
136
|
+
if choice == "neither":
|
|
137
|
+
result[dim]["neither"] += 1
|
|
138
|
+
continue
|
|
139
|
+
if choice not in ("a", "b"):
|
|
140
|
+
continue
|
|
141
|
+
label = row.get(f"{choice}_label") or choice
|
|
142
|
+
result[dim]["tally"][label] = result[dim]["tally"].get(label, 0) + 1
|
|
143
|
+
|
|
144
|
+
dimensions = {}
|
|
145
|
+
for dim, data in result.items():
|
|
146
|
+
n = data["n"]
|
|
147
|
+
decisive = sum(data["tally"].values())
|
|
148
|
+
if n < MIN_K or decisive == 0:
|
|
149
|
+
dimensions[dim] = {
|
|
150
|
+
"n": n,
|
|
151
|
+
"status": "insufficient",
|
|
152
|
+
"preferred": None,
|
|
153
|
+
"confidence": 0.0,
|
|
154
|
+
}
|
|
155
|
+
continue
|
|
156
|
+
max_count = max(data["tally"].values())
|
|
157
|
+
top_labels = [label for label, count in data["tally"].items() if count == max_count]
|
|
158
|
+
if len(top_labels) > 1:
|
|
159
|
+
# A genuine tie between the top two (or more) tallies: there is no
|
|
160
|
+
# lean to report, so `preferred` must be null rather than
|
|
161
|
+
# silently picking whichever label happens to sort last.
|
|
162
|
+
dimensions[dim] = {
|
|
163
|
+
"n": n,
|
|
164
|
+
"status": "tied",
|
|
165
|
+
"preferred": None,
|
|
166
|
+
"confidence": 0.0,
|
|
167
|
+
}
|
|
168
|
+
continue
|
|
169
|
+
preferred_label = top_labels[0]
|
|
170
|
+
confidence = wilson_lower_bound(max_count, decisive)
|
|
171
|
+
dimensions[dim] = {
|
|
172
|
+
"n": n,
|
|
173
|
+
"status": "confident",
|
|
174
|
+
"preferred": preferred_label,
|
|
175
|
+
"confidence": confidence,
|
|
176
|
+
}
|
|
177
|
+
return dimensions
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def detect_conflicts(dimensions: dict[str, dict], profile: dict) -> list[dict]:
|
|
181
|
+
conflicts = []
|
|
182
|
+
for dim, data in dimensions.items():
|
|
183
|
+
if data["status"] != "confident" or data["confidence"] < CONFIDENCE_STOP:
|
|
184
|
+
continue
|
|
185
|
+
link = _PROFILE_LINKS.get(dim)
|
|
186
|
+
if not link or link["profile_key"] not in profile:
|
|
187
|
+
continue
|
|
188
|
+
measured = profile[link["profile_key"]]
|
|
189
|
+
preferred = data["preferred"]
|
|
190
|
+
conflict_pole = None
|
|
191
|
+
if preferred == link["low_pole"] and measured >= link["high_threshold"]:
|
|
192
|
+
conflict_pole = link["high_pole"]
|
|
193
|
+
elif preferred == link["high_pole"] and measured <= link["low_threshold"]:
|
|
194
|
+
conflict_pole = link["low_pole"]
|
|
195
|
+
if conflict_pole is None:
|
|
196
|
+
continue
|
|
197
|
+
conflicts.append({
|
|
198
|
+
"dimension": dim,
|
|
199
|
+
"preferred": preferred,
|
|
200
|
+
"preferred_confidence": data["confidence"],
|
|
201
|
+
"preferred_provenance": "stated-preference",
|
|
202
|
+
"measured_key": link["profile_key"],
|
|
203
|
+
"measured_value": measured,
|
|
204
|
+
"measured_provenance": "measured-from-samples",
|
|
205
|
+
"message": (
|
|
206
|
+
f"Stated preference for '{preferred}' ({dim}) contradicts "
|
|
207
|
+
f"{link['profile_key']}={measured} measured from samples, "
|
|
208
|
+
f"which points toward '{conflict_pole}'."
|
|
209
|
+
),
|
|
210
|
+
})
|
|
211
|
+
return conflicts
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def next_dimension(dimensions: dict[str, dict]) -> dict:
|
|
215
|
+
def sort_key(dim: str) -> tuple:
|
|
216
|
+
data = dimensions[dim]
|
|
217
|
+
confidence = data["confidence"] if data["status"] == "confident" else 0.0
|
|
218
|
+
return (data["n"], confidence, DIMENSIONS.index(dim))
|
|
219
|
+
|
|
220
|
+
ordered = sorted(DIMENSIONS, key=sort_key)
|
|
221
|
+
chosen = ordered[0]
|
|
222
|
+
data = dimensions[chosen]
|
|
223
|
+
if data["n"] == min(dimensions[d]["n"] for d in DIMENSIONS):
|
|
224
|
+
reason = "fewest_observations"
|
|
225
|
+
else:
|
|
226
|
+
reason = "lowest_confidence"
|
|
227
|
+
return {
|
|
228
|
+
"next_dimension": chosen,
|
|
229
|
+
"reason": reason,
|
|
230
|
+
"n": data["n"],
|
|
231
|
+
"confidence": data["confidence"],
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
236
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
237
|
+
parser.add_argument("--preferences", required=True)
|
|
238
|
+
parser.add_argument("--profile")
|
|
239
|
+
parser.add_argument("--next", action="store_true")
|
|
240
|
+
return parser.parse_args(argv)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def main(argv: list[str]) -> int:
|
|
244
|
+
args = parse_args(argv)
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
rows = load_preferences(Path(args.preferences))
|
|
248
|
+
except OSError as e:
|
|
249
|
+
print(json.dumps({"error": f"could not read preferences file: {e}"}))
|
|
250
|
+
return 2
|
|
251
|
+
|
|
252
|
+
dimensions = aggregate(rows)
|
|
253
|
+
|
|
254
|
+
if args.next:
|
|
255
|
+
print(json.dumps(next_dimension(dimensions), indent=2, sort_keys=True))
|
|
256
|
+
return 0
|
|
257
|
+
|
|
258
|
+
output = {"dimensions": dimensions}
|
|
259
|
+
|
|
260
|
+
if args.profile:
|
|
261
|
+
try:
|
|
262
|
+
profile = json.loads(Path(args.profile).read_text())
|
|
263
|
+
except OSError as e:
|
|
264
|
+
print(json.dumps({"error": f"could not read profile file: {e}"}))
|
|
265
|
+
return 2
|
|
266
|
+
output["conflicts"] = detect_conflicts(dimensions, profile)
|
|
267
|
+
|
|
268
|
+
print(json.dumps(output, indent=2, sort_keys=True))
|
|
269
|
+
return 0
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate tiered detector pack integrity."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
11
|
+
PACK_DIR = ROOT / "references" / "packs"
|
|
12
|
+
MANIFEST = PACK_DIR / "manifest.json"
|
|
13
|
+
|
|
14
|
+
JUDGE_ONLY_MACROS = {
|
|
15
|
+
"macro_both_sidesism",
|
|
16
|
+
"macro_redemption_arc",
|
|
17
|
+
"macro_preview_recap",
|
|
18
|
+
"macro_over_determination",
|
|
19
|
+
"macro_emotional_flatness",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def scanner_categories() -> set[str]:
|
|
24
|
+
sys.path.insert(0, str(ROOT))
|
|
25
|
+
from scripts.banned_phrase_scan import BANNED_PHRASES, STRUCTURAL_PATTERNS
|
|
26
|
+
|
|
27
|
+
return {v["category"] for v in BANNED_PHRASES.values()} | {
|
|
28
|
+
v["category"] for v in STRUCTURAL_PATTERNS
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main() -> int:
|
|
33
|
+
failures: list[str] = []
|
|
34
|
+
data = json.loads(MANIFEST.read_text())
|
|
35
|
+
packs: dict[str, list[str]] = data["packs"]
|
|
36
|
+
category_to_pack: dict[str, str] = {}
|
|
37
|
+
|
|
38
|
+
for pack, categories in packs.items():
|
|
39
|
+
path = PACK_DIR / f"{pack}.md"
|
|
40
|
+
if not path.exists():
|
|
41
|
+
failures.append(f"missing pack file: {path}")
|
|
42
|
+
continue
|
|
43
|
+
text = path.read_text()
|
|
44
|
+
line_count = len(text.splitlines())
|
|
45
|
+
if line_count > 120:
|
|
46
|
+
failures.append(f"{path} has {line_count} lines; max 120")
|
|
47
|
+
if "## Emit" not in text:
|
|
48
|
+
failures.append(f"{path} missing ## Emit section")
|
|
49
|
+
for category in categories:
|
|
50
|
+
if category in category_to_pack:
|
|
51
|
+
failures.append(
|
|
52
|
+
f"category {category} mapped to both {category_to_pack[category]} and {pack}"
|
|
53
|
+
)
|
|
54
|
+
category_to_pack[category] = pack
|
|
55
|
+
|
|
56
|
+
scanner = scanner_categories()
|
|
57
|
+
missing = scanner - set(category_to_pack)
|
|
58
|
+
extra_scanner = (set(category_to_pack) - scanner) - JUDGE_ONLY_MACROS
|
|
59
|
+
if missing:
|
|
60
|
+
failures.append(f"scanner categories missing from packs: {sorted(missing)}")
|
|
61
|
+
if extra_scanner:
|
|
62
|
+
failures.append(f"unknown non-macro categories in manifest: {sorted(extra_scanner)}")
|
|
63
|
+
|
|
64
|
+
macro_hits = {m: category_to_pack.get(m) for m in JUDGE_ONLY_MACROS}
|
|
65
|
+
missing_macros = [m for m, pack in macro_hits.items() if pack is None]
|
|
66
|
+
if missing_macros:
|
|
67
|
+
failures.append(f"judge-only macro families missing: {sorted(missing_macros)}")
|
|
68
|
+
for macro, pack in macro_hits.items():
|
|
69
|
+
if pack and pack != "pack-structure":
|
|
70
|
+
failures.append(f"{macro} must live in pack-structure, found {pack}")
|
|
71
|
+
|
|
72
|
+
if failures:
|
|
73
|
+
print("\n".join(failures), file=sys.stderr)
|
|
74
|
+
return 1
|
|
75
|
+
print(f"pack integrity ok: {len(packs)} packs, {len(scanner)} scanner categories")
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Contract gates for co-writer suggestions.
|
|
3
|
+
|
|
4
|
+
Reads a suggestions file — the JSON emitted by suggest.py, i.e. an object with a
|
|
5
|
+
``document`` string and a ``suggestions`` list — and enforces four blocking
|
|
6
|
+
contracts. Each failure is named so a caller can act on it:
|
|
7
|
+
|
|
8
|
+
span-minimality Every suggested_replacement edits only its own span:
|
|
9
|
+
span.text matches document[start:end], the replacement
|
|
10
|
+
differs from the span text, and it shares no leading or
|
|
11
|
+
trailing whole word with the span (which would mean the
|
|
12
|
+
span grabbed unchanged text and could be shrunk — the
|
|
13
|
+
signature of a whole-sentence rewrite).
|
|
14
|
+
replacement-scanner Every suggested_replacement passes both scanners in
|
|
15
|
+
isolation AND introduces no new violation in context.
|
|
16
|
+
accept-all Applying every replacement yields a document that passes
|
|
17
|
+
both scanners with validate_preservation exit 0 vs the
|
|
18
|
+
original.
|
|
19
|
+
span-overlap Suggestion spans must not overlap.
|
|
20
|
+
|
|
21
|
+
Exit 0 when all gates pass, 1 otherwise. Failures (with the offending gate) are
|
|
22
|
+
reported as JSON on stdout.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
python3 scripts/check_suggestions.py suggestions.json
|
|
26
|
+
python3 scripts/check_suggestions.py < suggestions.json
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import sys
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from banned_phrase_scan import scan_for_violations
|
|
37
|
+
from structure_scan import scan as structure_scan
|
|
38
|
+
from validate_preservation import validate_preservation
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _leading_shared_words(a: str, b: str) -> int:
|
|
42
|
+
"""Number of identical leading whole words shared by two strings."""
|
|
43
|
+
n = 0
|
|
44
|
+
for x, y in zip(a.split(), b.split()):
|
|
45
|
+
if x == y:
|
|
46
|
+
n += 1
|
|
47
|
+
else:
|
|
48
|
+
break
|
|
49
|
+
return n
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _trailing_shared_words(a: str, b: str) -> int:
|
|
53
|
+
ra = " ".join(reversed(a.split()))
|
|
54
|
+
rb = " ".join(reversed(b.split()))
|
|
55
|
+
return _leading_shared_words(ra, rb)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _line_starts(text: str) -> list[int]:
|
|
59
|
+
starts = [0]
|
|
60
|
+
for i, ch in enumerate(text):
|
|
61
|
+
if ch == "\n":
|
|
62
|
+
starts.append(i + 1)
|
|
63
|
+
return starts
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def violation_spans(text: str) -> list[tuple[int, int]]:
|
|
67
|
+
"""Absolute (start, end) offsets of every banned-phrase/structural violation."""
|
|
68
|
+
starts = _line_starts(text)
|
|
69
|
+
out = []
|
|
70
|
+
for v in scan_for_violations(text):
|
|
71
|
+
s = starts[v["line_number"] - 1] + v["column"] - 1
|
|
72
|
+
out.append((s, s + len(v["phrase"])))
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _scanners_clean(text: str) -> bool:
|
|
77
|
+
return not scan_for_violations(text) and not structure_scan(text).get("flags")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def apply_all(document: str, suggestions: list[dict]) -> str:
|
|
81
|
+
"""Apply every non-null replacement, right-to-left so offsets stay valid."""
|
|
82
|
+
out = document
|
|
83
|
+
for s in sorted(suggestions, key=lambda s: s["span"]["start"], reverse=True):
|
|
84
|
+
rep = s.get("suggested_replacement")
|
|
85
|
+
if rep is None:
|
|
86
|
+
continue
|
|
87
|
+
st, en = s["span"]["start"], s["span"]["end"]
|
|
88
|
+
out = out[:st] + rep + out[en:]
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def check(document: str, suggestions: list[dict]) -> list[dict]:
|
|
93
|
+
failures: list[dict] = []
|
|
94
|
+
|
|
95
|
+
# span-overlap: spans must be disjoint.
|
|
96
|
+
order = sorted(range(len(suggestions)),
|
|
97
|
+
key=lambda i: (suggestions[i]["span"]["start"], suggestions[i]["span"]["end"]))
|
|
98
|
+
last_end = None
|
|
99
|
+
last_i = None
|
|
100
|
+
for i in order:
|
|
101
|
+
st, en = suggestions[i]["span"]["start"], suggestions[i]["span"]["end"]
|
|
102
|
+
if last_end is not None and st < last_end:
|
|
103
|
+
failures.append({
|
|
104
|
+
"gate": "span-overlap",
|
|
105
|
+
"suggestions": [last_i, i],
|
|
106
|
+
"detail": f"span {st}-{en} overlaps the previous span ending at {last_end}",
|
|
107
|
+
})
|
|
108
|
+
last_end, last_i = en, i
|
|
109
|
+
|
|
110
|
+
# span-minimality: span accuracy + tight, local replacements.
|
|
111
|
+
for i, s in enumerate(suggestions):
|
|
112
|
+
st, en = s["span"]["start"], s["span"]["end"]
|
|
113
|
+
span_text = document[st:en]
|
|
114
|
+
if span_text != s["span"]["text"]:
|
|
115
|
+
failures.append({
|
|
116
|
+
"gate": "span-minimality",
|
|
117
|
+
"suggestion": i,
|
|
118
|
+
"detail": "span.text does not match document[start:end]",
|
|
119
|
+
})
|
|
120
|
+
rep = s.get("suggested_replacement")
|
|
121
|
+
if rep is None:
|
|
122
|
+
continue
|
|
123
|
+
if rep == span_text:
|
|
124
|
+
failures.append({
|
|
125
|
+
"gate": "span-minimality",
|
|
126
|
+
"suggestion": i,
|
|
127
|
+
"detail": "replacement is identical to the span text (no change)",
|
|
128
|
+
})
|
|
129
|
+
continue
|
|
130
|
+
if _leading_shared_words(span_text, rep) > 0 or _trailing_shared_words(span_text, rep) > 0:
|
|
131
|
+
failures.append({
|
|
132
|
+
"gate": "span-minimality",
|
|
133
|
+
"suggestion": i,
|
|
134
|
+
"detail": "replacement shares leading/trailing whole words with the span; "
|
|
135
|
+
"shrink the span so the edit is minimal",
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
# replacement-scanner: each replacement is clean alone and in context.
|
|
139
|
+
for i, s in enumerate(suggestions):
|
|
140
|
+
rep = s.get("suggested_replacement")
|
|
141
|
+
if rep is None:
|
|
142
|
+
continue
|
|
143
|
+
if not _scanners_clean(rep):
|
|
144
|
+
failures.append({
|
|
145
|
+
"gate": "replacement-scanner",
|
|
146
|
+
"suggestion": i,
|
|
147
|
+
"detail": "replacement does not pass both scanners in isolation",
|
|
148
|
+
})
|
|
149
|
+
st, en = s["span"]["start"], s["span"]["end"]
|
|
150
|
+
ctx = document[:st] + rep + document[en:]
|
|
151
|
+
new_start, new_end = st, st + len(rep)
|
|
152
|
+
if any(a < new_end and new_start < b for (a, b) in violation_spans(ctx)):
|
|
153
|
+
failures.append({
|
|
154
|
+
"gate": "replacement-scanner",
|
|
155
|
+
"suggestion": i,
|
|
156
|
+
"detail": "replacement introduces a violation in context",
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
# accept-all: applying everything yields a clean, constraint-preserving doc.
|
|
160
|
+
unresolved = [i for i, s in enumerate(suggestions) if s.get("suggested_replacement") is None]
|
|
161
|
+
if unresolved:
|
|
162
|
+
failures.append({
|
|
163
|
+
"gate": "accept-all",
|
|
164
|
+
"detail": f"suggestions {unresolved} have no replacement; cannot accept-all",
|
|
165
|
+
})
|
|
166
|
+
applied = apply_all(document, suggestions)
|
|
167
|
+
if not _scanners_clean(applied):
|
|
168
|
+
failures.append({
|
|
169
|
+
"gate": "accept-all",
|
|
170
|
+
"detail": "the accept-all document still fails a scanner",
|
|
171
|
+
})
|
|
172
|
+
preservation = validate_preservation(document, applied)
|
|
173
|
+
if not preservation["passed"]:
|
|
174
|
+
failures.append({
|
|
175
|
+
"gate": "accept-all",
|
|
176
|
+
"detail": "validate_preservation failed against the original",
|
|
177
|
+
"missing": preservation["missing"],
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
return failures
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
184
|
+
parser = argparse.ArgumentParser(description="Contract gates for co-writer suggestions.")
|
|
185
|
+
parser.add_argument(
|
|
186
|
+
"path", nargs="?", help="Path to a suggestions JSON file (default: read stdin)"
|
|
187
|
+
)
|
|
188
|
+
return parser.parse_args(argv)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main(argv: list[str]) -> int:
|
|
192
|
+
args = parse_args(argv)
|
|
193
|
+
if args.path:
|
|
194
|
+
path = Path(args.path)
|
|
195
|
+
if not path.exists():
|
|
196
|
+
print(f"Missing file: {path}", file=sys.stderr)
|
|
197
|
+
return 2
|
|
198
|
+
raw = path.read_text(errors="replace")
|
|
199
|
+
else:
|
|
200
|
+
raw = sys.stdin.buffer.read().decode("utf-8", errors="replace")
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
data = json.loads(raw)
|
|
204
|
+
except json.JSONDecodeError as e:
|
|
205
|
+
print(json.dumps({"passed": False, "error": f"invalid JSON: {e}"}, indent=2))
|
|
206
|
+
return 1
|
|
207
|
+
|
|
208
|
+
document = data.get("document")
|
|
209
|
+
suggestions = data.get("suggestions", [])
|
|
210
|
+
if not isinstance(document, str):
|
|
211
|
+
print(json.dumps({"passed": False, "error": "missing 'document' string"}, indent=2))
|
|
212
|
+
return 1
|
|
213
|
+
|
|
214
|
+
failures = check(document, suggestions)
|
|
215
|
+
result = {
|
|
216
|
+
"passed": not failures,
|
|
217
|
+
"failure_gates": sorted({f["gate"] for f in failures}),
|
|
218
|
+
"failures": failures,
|
|
219
|
+
}
|
|
220
|
+
print(json.dumps(result, indent=2))
|
|
221
|
+
return 0 if not failures else 1
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__":
|
|
225
|
+
raise SystemExit(main(sys.argv[1:]))
|