The Q119 DONE record shipped at 2615c32 without its artifacts: classify.py and
verify.sh were modified and test_classify_fallback.py untracked in the worktree,
so every path the record names read MISSING at the tree about to be committed
(path-claims leg: 2 new rows). The three files land here byte-for-byte as the
record pins them -- classify.py md5 89a5b9d3476265069a0bf2694af9510e (162 lines),
verify.sh md5 4747faef183017c79620d3daf5d918c3 (105 lines),
test_classify_fallback.py md5 4bc2b53d4bcde155616d4741c708bc4a (159 lines) --
and bash wizard-work/sol/verify.sh reads CHECK OK, Ran 10 tests OK, rc 0 in this
tree. Not claimed: that anything in Q119's grading changed; this is its landing.
160 lines
6.6 KiB
Python
160 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Cells for the sol classifier's fallback contract (Q119).
|
|
|
|
The defect these cells pin: an item that matched no category rule used to be
|
|
returned as `general` -- a real category, at score 0 -- so a demand the rule set
|
|
did not understand was indistinguishable from one it had understood and scored.
|
|
The contract now is `unclassified` at score 0, counted beside the three real
|
|
categories so the aggregate still sums to the item count.
|
|
|
|
The invariant is one-directional on purpose: a *matched* item may be any real
|
|
category (the taxonomy is not this file's business), but a zero-score item may
|
|
never be. Four of Q119's five repro strings are zero-score under the current
|
|
rules; the fifth, "Triage the inbound queue before standup.", is a `general`
|
|
rule hit on `standup` at score 1 -- measured, and pinned as such in
|
|
`test_repro_with_a_rule_hit_is_classified_by_rule` rather than being forced
|
|
into the fallback to make a table look tidy.
|
|
"""
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
sys.path.insert(0, str(HERE))
|
|
|
|
import classify # noqa: E402 (the module under test, imported after sys.path)
|
|
|
|
# Q119's five hand-written imperatives, verbatim.
|
|
REPRO = [
|
|
"Ship the manifest to ops by Friday.",
|
|
"Move the nightly cron to the cheaper provider tonight.",
|
|
"We need a call on the provider tonight - reply by 17:00.",
|
|
"Triage the inbound queue before standup.",
|
|
"Roll back the ingress change.",
|
|
]
|
|
ZERO_SCORE_REPRO = [t for t in REPRO if "standup" not in t]
|
|
|
|
|
|
class FallbackContract(unittest.TestCase):
|
|
def test_zero_score_always_reads_unclassified(self):
|
|
for text in ZERO_SCORE_REPRO:
|
|
with self.subTest(text=text):
|
|
cat, score, reason = classify.classify(text)
|
|
self.assertEqual(score, 0)
|
|
self.assertEqual(
|
|
cat,
|
|
classify.UNCLASSIFIED,
|
|
"%r landed in %r, want %r" % (
|
|
text, cat, classify.UNCLASSIFIED),
|
|
)
|
|
self.assertIn("unclassified", reason)
|
|
|
|
def test_zero_score_never_lands_in_a_real_category(self):
|
|
for text in ZERO_SCORE_REPRO:
|
|
with self.subTest(text=text):
|
|
cat, score, _ = classify.classify(text)
|
|
if score == 0:
|
|
self.assertNotIn(cat, classify.CATS)
|
|
|
|
def test_every_repro_is_labelled_explicitly(self):
|
|
# No string may be ambiguous: either it matched (score >= 1 and a
|
|
# `matched` reason) or it is the explicit fallback. A real category at
|
|
# score 0 is the silent default Q119 filed.
|
|
for text in REPRO:
|
|
with self.subTest(text=text):
|
|
cat, score, reason = classify.classify(text)
|
|
if score == 0:
|
|
self.assertEqual(cat, classify.UNCLASSIFIED)
|
|
else:
|
|
self.assertIn(cat, classify.CATS)
|
|
self.assertIn("matched", reason)
|
|
|
|
def test_repro_with_a_rule_hit_is_classified_by_rule(self):
|
|
cat, score, reason = classify.classify(
|
|
"Triage the inbound queue before standup."
|
|
)
|
|
self.assertEqual(cat, "general")
|
|
self.assertEqual(score, 1)
|
|
self.assertIn("standup", reason)
|
|
|
|
def test_unclassified_is_counted_beside_real_categories(self):
|
|
self.assertNotIn(classify.UNCLASSIFIED, classify.CATS)
|
|
self.assertIn(classify.UNCLASSIFIED, classify.COUNTED)
|
|
self.assertEqual(
|
|
sorted(classify.COUNTED),
|
|
sorted(classify.CATS + [classify.UNCLASSIFIED]),
|
|
)
|
|
|
|
def test_matched_item_still_classifies(self):
|
|
# Control: a phrase that does match a rule is not swept into the
|
|
# fallback by the repair. "still renders" is an archive_fix rule.
|
|
cat, score, reason = classify.classify(
|
|
"The page still renders a dead clone URL."
|
|
)
|
|
self.assertEqual(cat, "archive_fix")
|
|
self.assertGreaterEqual(score, 1)
|
|
self.assertIn("matched", reason)
|
|
|
|
def test_unclassified_reason_names_no_category(self):
|
|
_, _, reason = classify.classify(ZERO_SCORE_REPRO[0])
|
|
for cat in classify.CATS:
|
|
self.assertNotIn(
|
|
cat,
|
|
reason,
|
|
"fallback reason claims category %r: %r" % (cat, reason),
|
|
)
|
|
|
|
def test_aggregate_counts_unclassified_and_sums_to_items(self):
|
|
seed = {
|
|
"pending": [
|
|
{"id": 1, "evidence": "README still renders a dead URL",
|
|
"proposed_action": "publish release notes"},
|
|
{"id": 2, "evidence": "Ship the manifest to ops by Friday.",
|
|
"proposed_action": ""},
|
|
{"id": 3, "evidence": "Roll back the ingress change.",
|
|
"proposed_action": ""},
|
|
]
|
|
}
|
|
with tempfile.TemporaryDirectory() as td:
|
|
seed_path = Path(td) / "seed.json"
|
|
out_path = Path(td) / "out.json"
|
|
seed_path.write_text(json.dumps(seed), encoding="utf-8")
|
|
proc = subprocess.run(
|
|
[sys.executable, str(HERE / "classify.py"),
|
|
"--seed", str(seed_path), "--output", str(out_path)],
|
|
capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
art = json.loads(out_path.read_text(encoding="utf-8"))
|
|
self.assertEqual(art["categories"].get("unclassified"), 2)
|
|
self.assertEqual(sum(art["categories"].values()), len(art["items"]))
|
|
by_id = {it["id"]: it for it in art["items"]}
|
|
self.assertEqual(by_id[2]["category"], classify.UNCLASSIFIED)
|
|
self.assertEqual(by_id[3]["category"], classify.UNCLASSIFIED)
|
|
self.assertIn(by_id[1]["category"], classify.CATS)
|
|
|
|
def test_cli_refuses_empty_seed(self):
|
|
with tempfile.TemporaryDirectory() as td:
|
|
seed_path = Path(td) / "seed.json"
|
|
out_path = Path(td) / "out.json"
|
|
seed_path.write_text(json.dumps({"pending": []}), encoding="utf-8")
|
|
proc = subprocess.run(
|
|
[sys.executable, str(HERE / "classify.py"),
|
|
"--seed", str(seed_path), "--output", str(out_path)],
|
|
capture_output=True, text=True,
|
|
)
|
|
self.assertEqual(proc.returncode, 1)
|
|
self.assertIn("no pending items", proc.stderr)
|
|
|
|
def test_source_has_no_general_default_string(self):
|
|
src = (HERE / "classify.py").read_text(encoding="utf-8")
|
|
self.assertNotIn("defaulted to general", src)
|
|
self.assertIn("unclassified", src)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|