diff --git a/README.md b/README.md index dfc5a67..d83307a 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ Everything else runs without them. Python 3.11 or newer. | Command (from a checkout) | Installed as | What it does | | --- | --- | --- | -| `./gen_bittings.py` | `sfic-gen-bittings` | Random bittings from a parity pattern, or with any parity if you give none, optionally staying away from existing bittings. | -| `./check_bittings.py` | `sfic-check-bittings` | Quick check of a few `NAME=BITTING` values: format, parity, adjacent-cut limit, pairwise closeness. | +| `./gen_bittings.py` | `sfic-gen-bittings` | Random bittings from a parity pattern, or with any parity if you give none, optionally staying away from existing bittings, and following the [key shape rules](#key-shape-rules). | +| `./check_bittings.py` | `sfic-check-bittings` | Quick check of a few `NAME=BITTING` values: format, parity, adjacent-cut limit, pairwise closeness, with advice on the [key shape rules](#key-shape-rules). | | `./check_system.py` | `sfic-check-system` | Whole-scheme check of a system file: per-key rules, duplicates, closeness, operating-set sizes, cross-operation, residual risk. | | `./solve_system.py` | `sfic-solve-system` | Fills in the `null` bittings of a system file by random search plus hill climbing, then runs the full check. | | `./pin_system.py` | `sfic-pin-system` | Prints the pinning chart of every core of a system file that sets `pinning`, in the layout `check_charts` reads: the pins to put in each chamber, with `--draw` a drawing of the stacks and with `--pdf` one PDF to print. | @@ -89,6 +89,42 @@ only estimate the chance that they cross-operate. The estimate assumes every undecoded unit key is a random valid bitting, uniformly chosen. If your real keys were chosen differently, the numbers do not apply. +## Key shape rules + +Beside the rules a key must follow to cut and pin (cut depths, the adjacent-cut limit, parity), +there are rules about what a key should look like. They keep the tools from handing you a key +with equal neighbouring cuts, one depth over and over, or a master that is shallow from end +to end. Without a parity pattern more than half of all valid bittings have a pair of equal +adjacent cuts, so a generator that ignored this would give you one more often than not. + +| Rule | Flag | Default | A key breaks it when | +| --- | --- | --- | --- | +| `max_run` | `--max-run N` | 1 | more than `N` equal cuts in a row (1 means no equal neighbours) | +| `max_same_depth` | `--max-same-depth N` | 3 | any depth appears more than `N` times | +| `forbid_monotone` | `--allow-monotone` (turns it off) | on | the cuts never go down, or never go up, along a key of three or more cuts | +| `master_min_span` | `--master-min-span N` | 6 | (masters only) the deepest cut minus the shallowest is under `N` | +| `min_total_variation` | `--min-variation N` | off | the sum of the differences between adjacent cuts is under `N` | + +Give `off` instead of a number to turn a rule off. The span rule's default is lower if no key +of the system could span 6 (few pins, or a small `max_step`). The rules are ours: the +industry names rules of this kind, but the thresholds are not public. +The reasons, the numbers behind the defaults and what each costs in key space are in +[docs/designs/key-shape-rules.md](docs/designs/key-shape-rules.md). + +- `gen_bittings.py` only prints bittings that follow them. Add `--master` when you are + drawing master keys, so the span rule applies. If the search cannot find enough + bittings it says which shape rules turned draws down; it never relaxes one on its own. +- `check_bittings.py` prints a `SHAPE` line for each rule a key breaks, with pin positions and + never the cuts. They are advice: they do not count as problems or change the exit status. + Name the masters with `--master NAME` (repeat the flag for more than one). +- The solver and `check_system` do not use the rules yet. + +```bash +./gen_bittings.py --pins 7 -n 3 --master # three masters, no parity pattern +./gen_bittings.py OOEOEOE -n 8 --max-run 2 # allow a pair of equal neighbours +./check_bittings.py --master top top=0453037 area=6130254 +``` + ## System file format A JSON file. Fields (only `keys` and `cores` are needed for the basics): @@ -327,7 +363,8 @@ printed, and the printed pins are the ones the checker's own pinner computed. ``` Output is in generation order (not sorted), so taking the first one is as - random as any other. Spot-check candidates with + random as any other. Add `--master` for master keys (see + [Key shape rules](#key-shape-rules)). Spot-check candidates with `./check_bittings.py --pattern OOEOEOE name=1234567 other=7654321`. 2. **Write the system file.** List keys, retired keys and the `cores` hierarchy. diff --git a/docs/design.md b/docs/design.md index 61c443f..2b2aa59 100644 --- a/docs/design.md +++ b/docs/design.md @@ -113,7 +113,8 @@ path (D1). The shared modules are `model.py` (the pure maths, built around `KeySpace`), `config.py` (loading and validating a system file), `pinning.py` (pinning system records and the pinner), `population.py` (who the undecoded unit keys might be, D55), `joint.py` (the -exact construction of pinnable bittings, D60), `lock.py` (the simulated lock), `charts.py` +exact construction of pinnable bittings, D60), `shape_args.py` (the command-line flags for +the shape rules, D64), `lock.py` (the simulated lock), `charts.py` and `chartwriter.py` (reading and writing pinning charts) and `pdfwriter.py` (writing pages of text as a PDF). The tools are `gen_bittings`, `check_bittings`, `check_system`, `solve_system`, `pin_system`, `check_charts` and `scan_charts`, whose scanning stages live diff --git a/docs/designs/key-shape-rules.md b/docs/designs/key-shape-rules.md index 6f66a4c..1448c1e 100644 --- a/docs/designs/key-shape-rules.md +++ b/docs/designs/key-shape-rules.md @@ -150,8 +150,13 @@ what the tools count alone. The risk figures are unchanged. bound, and a set small enough to list is filtered whole. If the bound is reached with none, the group is reported as above. - **`gen_bittings`.** Applies the defaults, with `--max-run`, `--max-same-depth`, - `--min-variation` and `--master` to change them, and `--allow-monotone`. -- **`check_bittings`, `check_system`.** The advisory `SHAPE` lines. + `--min-variation`, `--master-min-span` and `--allow-monotone` to change them and `--master` + to say the bittings are masters. A number, or `off`, turns a rule on or off; a flag that no + key of the key space could meet is a usage error. The flags are shared with + `check_bittings` (`shape_args.py`). When the search gives up, the message says which rules + turned draws down. +- **`check_bittings`, `check_system`.** The advisory `SHAPE` lines. `check_bittings` takes + the same flags and names its masters with `--master NAME`, repeated. - **System file.** `shape` is optional; a file without it gets the defaults, so seeded solver output changes for existing files. That is deliberate and the log entry says so (D6 asks for a stated reason). An unknown field inside `shape` is an error, not a warning, @@ -205,6 +210,12 @@ shows seeded output unchanged for files that turn every rule off. ## What cannot be known yet +- Whether the rules can be met together. The reachability bounds look at each rule alone and + ignore the parity pattern, so a combination can pass them and still have no key: 3 pins + with `--master` and the default `max_step` of 5 need a span of 6, which no key of three + cuts has unless it is monotone, which the default rules forbid. The tool then runs to its + attempt limit and says so, naming the rule that turned down most of the draws, and does + not relax anything. Three pins is not a real key, so the bounds were left simple. - How many pinnable candidates survive the rules in a system with many known keys: a unit master that must also take a decoded unit's neighbours has a thin pinnable set already, and the first run on such a file will say whether `master_min_span` = 6 is too strict. diff --git a/sfic_solver/check_bittings.py b/sfic_solver/check_bittings.py index ad5ad48..47b6b36 100644 --- a/sfic_solver/check_bittings.py +++ b/sfic_solver/check_bittings.py @@ -2,11 +2,14 @@ """Sanity-check a set of bittings (7 pins unless --pins or --pattern says otherwise). Checks each bitting for format, parity (if --pattern is given) and the -adjacent-cut limit, then lists every pair closer than --min-diff positions. +adjacent-cut limit, then lists every pair closer than --min-diff positions. It also +notes any bitting that breaks a shape rule (docs/designs/key-shape-rules.md): SHAPE +lines are advice, positions only, and do not count as problems or change the exit status. Examples: ./check_bittings.py --pattern OOEOEOE key_a=5961634 key_b=7305496 key_c=5721276 ./check_bittings.py --min-diff 5 key_a=5961634 key_b=7305496 new_master=7587672 + ./check_bittings.py --master new_master a=1234567 new_master=7587672 Exits with status 1 if anything is flagged. """ @@ -14,6 +17,7 @@ import itertools from . import model +from .shape_args import add_shape_arguments, shape_rules def parse_item(text, space): @@ -37,6 +41,10 @@ def main(argv=None): ap.add_argument("--min-diff", type=int, help="flag pairs differing in fewer positions than this " "(default 5, or the pin count if smaller)") + ap.add_argument("--master", action="append", default=[], metavar="NAME", + help="the name of a key that is a master, so the master span rule " + "applies to it; repeat the flag for more than one") + add_shape_arguments(ap) args = ap.parse_args(argv) if args.pins is not None and args.pins < 1: @@ -66,7 +74,14 @@ def main(argv=None): except ValueError as err: ap.error(str(err)) + names = [name for name, _ in keys] + for master in args.master: + if master not in names: + ap.error(f"--master {master!r} is not one of the keys given") + rules = shape_rules(ap, args, space) + problems = 0 + notes = 0 for name, cuts in keys: if pattern: bad = space.parity_bad(cuts) @@ -77,6 +92,9 @@ def main(argv=None): if big: print(f"MACS {name}: adjacent cuts too far apart after pin(s) {big}") problems += 1 + for rule, pins_ in rules.violations(cuts, name in args.master): + print(f"SHAPE {name}: {rules.describe(rule, pins_)}") + notes += 1 pairs = sorted(((model.distance(a[1], b[1]), a[0], b[0]) for a, b in itertools.combinations(keys, 2))) @@ -88,6 +106,8 @@ def main(argv=None): closest = pairs[0] if pairs else None if closest: print(f"closest pair: {closest[1]} vs {closest[2]} ({closest[0]} positions)") + if notes: + print(f"{notes} shape note(s): advice only, not counted as problems") print("OK" if not problems else f"{problems} problem(s) flagged") return 1 if problems else 0 diff --git a/sfic_solver/config.py b/sfic_solver/config.py index baab76f..cd97cae 100644 --- a/sfic_solver/config.py +++ b/sfic_solver/config.py @@ -94,15 +94,9 @@ def _shape_rules(raw, space): rules = model.ShapeRules.for_space(space, **fields) except ValueError as err: raise ConfigError(f"shape: {err}") from None - if rules.master_min_span is not None and rules.master_min_span > space.widest_span: - raise ConfigError(f"shape: master_min_span is {rules.master_min_span} but no key can " - f"span more than {space.widest_span} ({space.pins} pins, cuts 0 to " - f"{space.depths - 1}, max_step is {space.max_step})") - if (rules.min_total_variation is not None - and rules.min_total_variation > space.most_variation): - raise ConfigError(f"shape: min_total_variation is {rules.min_total_variation} but no " - f"key of {space.pins} pins can vary by more than " - f"{space.most_variation} (max_step is {space.max_step})") + problem = rules.unmeetable(space) + if problem: + raise ConfigError(f"shape: {problem}") return rules diff --git a/sfic_solver/gen_bittings.py b/sfic_solver/gen_bittings.py index 983e0be..a70cf7c 100644 --- a/sfic_solver/gen_bittings.py +++ b/sfic_solver/gen_bittings.py @@ -9,16 +9,25 @@ keys can be pinned together depends on the core, so check_system and solve_system are where that is tested, not this command. +Keys also follow the shape rules (docs/designs/key-shape-rules.md): by default no equal +adjacent cuts, no depth used more than 3 times, no key whose cuts only go one way, and, with +--master, a span of at least 6 between the deepest and shallowest cut. The flags under "key +shape rules" change them; a number, or "off", for each. + Examples: ./gen_bittings.py OOEOEOE ./gen_bittings.py OOEOEOE -n 20 ./gen_bittings.py OOEOEOE -n 5 --avoid 5961634 7305496 --min-diff 4 ./gen_bittings.py --pins 7 -n 5 # any parity + ./gen_bittings.py --pins 7 -n 3 --master # masters must also span the depths + ./gen_bittings.py OOEOEOE --max-run 2 # allow a pair of equal neighbours """ import argparse +import collections import secrets from . import model +from .shape_args import FLAGS, add_shape_arguments, shape_rules MAX_ATTEMPTS = 100_000 @@ -50,6 +59,25 @@ def generate(space): raise RuntimeError("no valid bitting found; is --max-step too small?") +MOST_DRAWS = 0.8 + + +def give_up_message(rejected, draws): + """Why the search ended: the old advice, and which shape rules turned draws down, by the + flag that governs each. A rule that turned down most of the draws is named, since that + usually means it cannot be met together with the others.""" + message = "could not find enough bittings; loosen the constraints" + if rejected: + counts = ", ".join(f"{FLAGS[name]} {count:,}" for name, count in rejected.most_common()) + message += (f" (draws that broke a shape rule, by the flag that governs it: {counts}; " + f"see the key shape rules flags in --help)") + name, count = rejected.most_common(1)[0] + if draws and count >= MOST_DRAWS * draws: + message += (f". {FLAGS[name]} turned down {count / draws:.0%} of the draws, so it " + f"may not be possible with the other rules") + return message + + def differs_enough(cuts, avoid, min_diff): return all(sum(a != b for a, b in zip(cuts, old)) >= min_diff for old in avoid) @@ -71,6 +99,9 @@ def main(argv=None): ap.add_argument("--min-diff", type=int, help="min positions that must differ from each --avoid bitting " "and from each other (default 3, or the pin count if smaller)") + ap.add_argument("--master", action="store_true", + help="the bittings are master keys: the master span rule applies to them") + add_shape_arguments(ap) args = ap.parse_args(argv) if args.max_step < 1: @@ -86,18 +117,24 @@ def main(argv=None): avoid = [parse_bitting(b, space) for b in args.avoid] except ValueError as err: ap.error(str(err)) + rules = shape_rules(ap, args, space) if args.min_diff is None: args.min_diff = min(3, pins) elif args.min_diff > pins: ap.error(f"--min-diff {args.min_diff} is more than the {pins} pins") accepted = [] + rejected = collections.Counter() attempts = 0 while len(accepted) < args.count: attempts += 1 if attempts > MAX_ATTEMPTS: - raise SystemExit("could not find enough bittings; loosen the constraints") + raise SystemExit(give_up_message(rejected, attempts - 1)) cuts = generate(space) + broken = rules.violations(cuts, args.master) + if broken: + rejected.update(name for name, _ in broken) + continue # New bittings must also stay --min-diff away from each other, not just # from the --avoid list. Also rejects duplicates when min-diff >= 1. if cuts not in accepted and differs_enough(cuts, avoid + accepted, args.min_diff): diff --git a/sfic_solver/model.py b/sfic_solver/model.py index 84c3d00..6b9b089 100644 --- a/sfic_solver/model.py +++ b/sfic_solver/model.py @@ -264,6 +264,40 @@ def for_space(cls, space, **fields): fields.setdefault("master_min_span", span if span >= 1 else None) return cls(**fields) + def describe(self, rule, pins): + """A phrase for a broken rule and its pins (from `violations`), saying where and + never what the cuts are.""" + where = f" at pin(s) {list(pins)}" if pins else "" + if rule == "max_run": + if self.max_run == 1: + return f"equal adjacent cuts{where}" + return f"more than {self.max_run} equal cuts in a row{where}" + if rule == "max_same_depth": + return f"a depth used more than {self.max_same_depth} times{where}" + if rule == "forbid_monotone": + return "the cuts only go one way along the key (never down, or never up)" + if rule == "master_min_span": + return (f"a master whose deepest and shallowest cuts differ by less than " + f"{self.master_min_span}") + if rule == "min_total_variation": + return f"total variation under {self.min_total_variation}" + raise ValueError(f"unknown shape rule {rule!r}") + + def unmeetable(self, space): + """A message saying why no key of the space could meet these rules, or None. The + bounds ignore the parity pattern, so a rule that some key could meet is never + reported, but one that none can may pass.""" + if self.master_min_span is not None and self.master_min_span > space.widest_span: + return (f"master_min_span is {self.master_min_span} but no key can span more than " + f"{space.widest_span} ({space.pins} pins, cuts 0 to {space.depths - 1}, " + f"max_step is {space.max_step})") + if (self.min_total_variation is not None + and self.min_total_variation > space.most_variation): + return (f"min_total_variation is {self.min_total_variation} but no key of " + f"{space.pins} pins can vary by more than {space.most_variation} " + f"(max_step is {space.max_step})") + return None + def violations(self, cuts, master=False): """The rules this bitting breaks, as (rule name, 1-based pins) in the order of SHAPE_RULES. The pins are those in an over-long run or of an over-used depth; the diff --git a/sfic_solver/shape_args.py b/sfic_solver/shape_args.py new file mode 100644 index 0000000..a759bf6 --- /dev/null +++ b/sfic_solver/shape_args.py @@ -0,0 +1,74 @@ +"""The command-line flags for the shape rules, shared by gen_bittings and check_bittings. + +A flag that is not given leaves the rule at its default for the key space; a count flag +takes a whole number of at least 1, or `off` to turn the rule off +(docs/designs/key-shape-rules.md). The tool-specific flag that says which keys are masters +(`--master`) is not here, because the two tools mean different things by it. +""" +import argparse + +from . import model + + +# The flag that governs each rule, for messages to the user. (`--allow-monotone` turns its +# rule off, so a message about that rule says "see" the flag, not "set" it.) +FLAGS = {"max_run": "--max-run", "max_same_depth": "--max-same-depth", + "forbid_monotone": "--allow-monotone", "master_min_span": "--master-min-span", + "min_total_variation": "--min-variation"} + + +def count_or_off(text): + """argparse type: a whole number of at least 1, or "off" (None) to turn a rule off.""" + if text.lower() in ("off", "none"): + return None + try: + value = int(text) + except ValueError: + value = 0 + if value < 1: + raise argparse.ArgumentTypeError( + f"expected a whole number of at least 1, or 'off', got {text!r}") + return value + + +def add_shape_arguments(ap): + """Add the shape-rule flags to a parser. Flags not given are absent from the result + (default=SUPPRESS), so `shape_rules` can tell "off" from "not given".""" + group = ap.add_argument_group( + "key shape rules", "each rule is on at its default unless a flag changes it; " + "a number, or 'off' to turn the rule off") + suppress = argparse.SUPPRESS + group.add_argument("--max-run", type=count_or_off, metavar="N", default=suppress, + help=f"most equal cuts in a row (default {model.DEFAULT_MAX_RUN}: " + f"no equal neighbours)") + group.add_argument("--max-same-depth", type=count_or_off, metavar="N", default=suppress, + help=f"most times one depth may appear in a key " + f"(default {model.DEFAULT_MAX_SAME_DEPTH})") + group.add_argument("--min-variation", dest="min_total_variation", type=count_or_off, + metavar="N", default=suppress, + help="least total variation (the sum of the differences between " + "adjacent cuts); off by default") + group.add_argument("--master-min-span", dest="master_min_span", type=count_or_off, + metavar="N", default=suppress, + help=f"least difference between a master's deepest and shallowest cut " + f"(default {model.DEFAULT_MASTER_MIN_SPAN}, or the widest a key " + f"can span if that is less)") + group.add_argument("--allow-monotone", dest="forbid_monotone", action="store_false", + default=suppress, + help="allow keys whose cuts never go down, or never go up") + + +def shape_rules(ap, args, space): + """The ShapeRules for the parsed flags and key space; a usage error (exit 2) if a rule + could not be met by any key of the space.""" + fields = {name: getattr(args, name) for name in model.SHAPE_RULES if hasattr(args, name)} + try: + rules = model.ShapeRules.for_space(space, **fields) + except ValueError as err: + ap.error(str(err)) + problem = rules.unmeetable(space) + if problem: + for name, flag in FLAGS.items(): + problem = problem.replace(name, flag) + ap.error(problem) + return rules diff --git a/tests/test_check_bittings.py b/tests/test_check_bittings.py index 1edf46e..32d4f06 100644 --- a/tests/test_check_bittings.py +++ b/tests/test_check_bittings.py @@ -116,3 +116,69 @@ def test_pins_must_be_positive(pins): proc = run_script("check_bittings", "--pins", pins, "a=012") assert proc.returncode == 2 assert "--pins must be at least 1" in proc.stderr + + +# -- shape notes (docs/designs/key-shape-rules.md) ------------------------------------- +# Invented keys. GOOD_A and GOOD_B have a good shape, follow MACS 5 and differ in 7 positions; +# PAIR has one pair of equal neighbours (pins 1 and 2); DEPTH uses the depth 3 four times; +# ONE_WAY never goes down; NARROW spans only 3 depths. +GOOD_A, GOOD_B = "0453037", "6130254" +PAIR, DEPTH, ONE_WAY, NARROW = "3361527", "3135313", "0123456", "3456543" + + +def test_equal_neighbours_are_noted_as_advice_and_do_not_fail(): + proc = run_script("check_bittings", f"a={PAIR}", f"b={GOOD_B}") + assert proc.returncode == 0, proc.stdout + assert "SHAPE a: equal adjacent cuts at pin(s) [1, 2]" in proc.stdout + assert "1 shape note(s): advice only, not counted as problems" in proc.stdout + assert proc.stdout.strip().endswith("OK") + + +def test_a_key_with_a_good_shape_has_no_notes(): + proc = run_script("check_bittings", f"a={GOOD_A}", f"b={GOOD_B}") + assert proc.returncode == 0 + assert "SHAPE" not in proc.stdout and "shape note" not in proc.stdout + + +def test_a_depth_used_too_often_and_a_one_way_key_are_noted(): + proc = run_script("check_bittings", f"a={DEPTH}", f"b={ONE_WAY}") + assert "SHAPE a: a depth used more than 3 times at pin(s) [1, 3, 5, 7]" in proc.stdout + assert "SHAPE b: the cuts only go one way along the key" in proc.stdout + + +def test_the_master_span_applies_only_to_named_masters(): + keys = [f"narrow={NARROW}", f"other={GOOD_B}"] + assert "master" not in run_script("check_bittings", *keys).stdout + proc = run_script("check_bittings", "--master", "narrow", *keys) + assert proc.returncode == 0 + assert ("SHAPE narrow: a master whose deepest and shallowest cuts differ by less than 6" + in proc.stdout) + assert "SHAPE other" not in proc.stdout + + +def test_a_master_name_that_is_not_a_key_is_a_usage_error(): + proc = run_script("check_bittings", "--master", "nobody", f"a={GOOD_A}") + assert proc.returncode == 2 + assert "--master 'nobody' is not one of the keys given" in proc.stderr + + +def test_shape_flags_change_the_notes(): + keys = [f"a={PAIR}", f"b={GOOD_B}"] + assert "SHAPE" not in run_script("check_bittings", "--max-run", "2", *keys).stdout + assert "SHAPE" not in run_script("check_bittings", "--max-run", "off", *keys).stdout + proc = run_script("check_bittings", "--min-variation", "30", *keys) + assert "SHAPE a: total variation under 30" in proc.stdout and proc.returncode == 0 + + +def test_shape_notes_and_real_problems_are_counted_apart(): + proc = run_script("check_bittings", "a=0900000", f"b={GOOD_B}") + assert proc.returncode == 1 + assert "MACS a:" in proc.stdout and "SHAPE a: equal adjacent cuts" in proc.stdout + assert "shape note(s): advice only" in proc.stdout + assert proc.stdout.strip().endswith("1 problem(s) flagged") + + +def test_a_shape_note_never_prints_the_cuts_of_a_named_key(): + proc = run_script("check_bittings", f"secret_key={PAIR}", f"b={GOOD_B}") + notes = [line for line in proc.stdout.splitlines() if line.startswith("SHAPE")] + assert notes and all(PAIR not in line for line in notes) diff --git a/tests/test_gen_bittings.py b/tests/test_gen_bittings.py index 67d73b3..be1892c 100644 --- a/tests/test_gen_bittings.py +++ b/tests/test_gen_bittings.py @@ -173,3 +173,128 @@ def test_without_a_pattern_the_default_closeness_follows_the_pin_count(): assert proc.returncode == 0 and len(keys) == 5 for a, b in itertools.combinations(keys, 2): assert distance(a, b) >= 3 + + +# -- shape rules (docs/designs/key-shape-rules.md) ------------------------------------ + +def shape_facts(key): + """(longest run, most times one depth appears, monotone, span) by plain loops.""" + cuts = [int(c) for c in key] + run = best = 1 + for a, b in zip(cuts, cuts[1:]): + run = run + 1 if a == b else 1 + best = max(best, run) + steps = [b - a for a, b in zip(cuts, cuts[1:])] + monotone = all(s >= 0 for s in steps) or all(s <= 0 for s in steps) + return best, max(cuts.count(d) for d in set(cuts)), monotone, max(cuts) - min(cuts) + + +def generate_many(monkeypatch, capsys, *args, seed=7): + monkeypatch.setattr(secrets, "choice", random.Random(seed).choice) + assert call_main(gen_bittings.main, [*args], monkeypatch) == 0 + return capsys.readouterr().out.split() + + +@pytest.mark.parametrize("args", [["--pins", "7"], ["OOEOEOE"], ["OEOEOEO"], ["--pins", "5"]]) +def test_default_output_follows_the_shape_rules(monkeypatch, capsys, args): + keys = generate_many(monkeypatch, capsys, *args, "-n", "300", "--min-diff", "0") + assert len(keys) == 300 + for key in keys: + run, depth, monotone, _ = shape_facts(key) + assert run == 1 and depth <= 3 and not monotone, key + + +def test_masters_also_span_at_least_six(monkeypatch, capsys): + keys = generate_many(monkeypatch, capsys, "--pins", "7", "-n", "300", "--min-diff", "0", + "--master") + assert all(shape_facts(key)[3] >= 6 for key in keys) + plain = generate_many(monkeypatch, capsys, "--pins", "7", "-n", "300", "--min-diff", "0") + assert any(shape_facts(key)[3] < 6 for key in plain) # the span rule is for masters + + +def test_max_run_two_allows_pairs_but_not_triples(monkeypatch, capsys): + keys = generate_many(monkeypatch, capsys, "--pins", "7", "-n", "300", "--min-diff", "0", + "--max-run", "2") + runs = {shape_facts(key)[0] for key in keys} + assert runs == {1, 2} + + +def test_off_and_allow_monotone_let_the_old_shapes_back(monkeypatch, capsys): + keys = generate_many(monkeypatch, capsys, "--pins", "7", "-n", "1500", "--min-diff", "0", + "--max-run", "off", "--max-same-depth", "off", "--allow-monotone", + "--master-min-span", "off") + facts = [shape_facts(key) for key in keys] + assert any(run >= 3 for run, *_ in facts) + assert any(depth >= 4 for _, depth, *_ in facts) + assert any(monotone for _, _, monotone, _ in facts) + + +def test_min_variation_is_applied(monkeypatch, capsys): + keys = generate_many(monkeypatch, capsys, "--pins", "7", "-n", "100", "--min-diff", "0", + "--min-variation", "20") + for key in keys: + cuts = [int(c) for c in key] + assert sum(abs(a - b) for a, b in zip(cuts, cuts[1:])) >= 20, key + + +def test_giving_up_says_which_shape_rules_turned_draws_down(monkeypatch): + monkeypatch.setattr(gen_bittings, "MAX_ATTEMPTS", 50) + with pytest.raises(SystemExit) as exc: + gen_bittings.main(["--pins", "4", "-n", "20", "--min-diff", "0", "--min-variation", "15"]) + message = str(exc.value.code) + assert "loosen the constraints" in message + assert "draws that broke a shape rule, by the flag that governs it:" in message + assert "--min-variation" in message and "min_total_variation" not in message + + +def test_giving_up_for_other_reasons_does_not_blame_the_shape_rules(): + assert gen_bittings.give_up_message({}, 100) == \ + "could not find enough bittings; loosen the constraints" + + +def test_the_give_up_message_names_flags_not_field_names(): + from collections import Counter + rejected = Counter(max_run=5, max_same_depth=4, forbid_monotone=3, master_min_span=2, + min_total_variation=1) + message = gen_bittings.give_up_message(rejected, 100) + for flag in ("--max-run 5", "--max-same-depth 4", "--allow-monotone 3", + "--master-min-span 2", "--min-variation 1"): + assert flag in message, message + for field in model.SHAPE_RULES: + assert field not in message, message + + +def test_a_rule_that_turned_down_most_draws_is_named(): + from collections import Counter + message = gen_bittings.give_up_message(Counter(master_min_span=88, max_run=22), 100) + assert message.endswith("--master-min-span turned down 88% of the draws, so it may not " + "be possible with the other rules") + quiet = gen_bittings.give_up_message(Counter(master_min_span=79, max_run=22), 100) + assert "turned down" not in quiet # under 80% + + +def test_every_rule_has_a_flag(): + from sfic_solver.shape_args import FLAGS + assert list(FLAGS) == list(model.SHAPE_RULES) + + +def test_three_pin_masters_that_no_key_can_be_say_so(monkeypatch): + """The reachability bound ignores how the rules combine: three cuts spanning 6 with steps + of at most 5 must be monotone, which the defaults forbid.""" + monkeypatch.setattr(gen_bittings, "MAX_ATTEMPTS", 300) + with pytest.raises(SystemExit) as exc: + gen_bittings.main(["--pins", "3", "-n", "2", "--master"]) + message = str(exc.value.code) + assert "--master-min-span turned down" in message and "of the draws" in message + + +@pytest.mark.parametrize("args, message", [ + (["--master-min-span", "12"], "--master-min-span is 12 but no key can span more than 9"), + (["--min-variation", "31"], "--min-variation is 31 but no key of 7 pins can vary"), + (["--max-run", "0"], "--max-run: expected a whole number of at least 1, or 'off'"), + (["--max-same-depth", "many"], "--max-same-depth: expected a whole number"), +]) +def test_bad_shape_flags_are_usage_errors(args, message): + proc = run_script("gen_bittings", *args) + assert proc.returncode == 2 and proc.stderr.startswith("usage:") + assert message in proc.stderr and "Traceback" not in proc.stderr diff --git a/tests/test_model.py b/tests/test_model.py index 519761c..87894ee 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -440,3 +440,17 @@ def test_shape_rules_without_a_pattern_match_the_oracle_on_a_sample(): cuts = tuple(rng.randrange(10) for _ in range(7)) master = rng.random() < 0.5 assert rules.ok(cuts, master) == shape_oracle(cuts, rules, master), cuts + + +def test_unmeetable_names_a_rule_no_key_could_meet(): + space = model.KeySpace(pins=5, max_step=1) # widest span 4, most variation 4 + assert model.ShapeRules.for_space(space).unmeetable(space) is None + assert model.ShapeRules(master_min_span=4, min_total_variation=4).unmeetable(space) is None + span = model.ShapeRules(master_min_span=5).unmeetable(space) + assert span == ("master_min_span is 5 but no key can span more than 4 " + "(5 pins, cuts 0 to 9, max_step is 1)") + variation = model.ShapeRules(master_min_span=None, min_total_variation=5).unmeetable(space) + assert variation == ("min_total_variation is 5 but no key of 5 pins can vary by more " + "than 4 (max_step is 1)") + off = model.ShapeRules(master_min_span=None, min_total_variation=None) + assert off.unmeetable(space) is None diff --git a/tests/test_readme.py b/tests/test_readme.py index 30ddb3b..bef384f 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -115,3 +115,41 @@ def test_the_readme_install_line_is_the_one_the_tool_prints(): assert INSTALL_HINT in readme assert "brew install tesseract" in readme and "apt install tesseract-ocr" in readme assert TESSERACT_ENV in readme + + +# -- the key shape rules table (D64) ------------------------------------------------- + +def shape_rule_rows(): + """{rule: (flag cell, default cell)} from the README's key shape rules table.""" + text = (ROOT / "README.md").read_text() + section = text.split("## Key shape rules")[1].split("\n## ")[0] + rows = re.findall(r"^\| `(\w+)` \| (.+?) \| (.+?) \| .+ \|$", section, re.M) + return {rule: (flag, default) for rule, flag, default in rows} + + +def test_readme_shape_table_has_every_rule_with_its_default(): + from sfic_solver import model + defaults = model.ShapeRules() + rows = shape_rule_rows() + assert list(rows) == list(model.SHAPE_RULES) + for rule, (_, shown) in rows.items(): + value = getattr(defaults, rule) + assert shown == ("off" if value is None else "on" if value is True else str(value)), rule + + +def test_readme_shape_table_names_every_flag_the_tools_take(): + import argparse + from sfic_solver.shape_args import add_shape_arguments + ap = argparse.ArgumentParser() + add_shape_arguments(ap) + flags = {s for action in ap._actions for s in action.option_strings if s.startswith("--")} + flags.discard("--help") + cells = " ".join(flag for flag, _ in shape_rule_rows().values()) + assert all(flag in cells for flag in flags), flags + + +def test_readme_shape_example_commands_run(): + proc = run_script("check_bittings", "--master", "top", "top=0453037", "area=6130254") + assert proc.returncode == 0 and "SHAPE" not in proc.stdout, proc.stdout + assert run_script("gen_bittings", "--pins", 7, "-n", 3, "--master").returncode == 0 + assert run_script("gen_bittings", "OOEOEOE", "-n", 8, "--max-run", 2).returncode == 0 diff --git a/tests/test_shape_args.py b/tests/test_shape_args.py new file mode 100644 index 0000000..ec16e9d --- /dev/null +++ b/tests/test_shape_args.py @@ -0,0 +1,55 @@ +"""The shape-rule flags shared by gen_bittings and check_bittings.""" +import argparse + +import pytest + +from sfic_solver import model +from sfic_solver.shape_args import add_shape_arguments, count_or_off, shape_rules + + +def parse(*argv, space=None): + ap = argparse.ArgumentParser(prog="tool") + add_shape_arguments(ap) + args = ap.parse_args(argv) + return shape_rules(ap, args, space or model.KeySpace(pins=7)) + + +@pytest.mark.parametrize("text, value", [("1", 1), ("3", 3), ("off", None), ("OFF", None), + ("none", None)]) +def test_count_or_off(text, value): + assert count_or_off(text) == value + + +@pytest.mark.parametrize("bad", ["0", "-2", "1.5", "two", "", "o"]) +def test_count_or_off_refuses_everything_else(bad): + with pytest.raises(argparse.ArgumentTypeError, match="at least 1, or 'off'"): + count_or_off(bad) + + +def test_no_flags_gives_the_defaults_for_the_key_space(): + assert parse() == model.ShapeRules() + assert parse(space=model.KeySpace(pins=5, max_step=1)).master_min_span == 4 + + +def test_each_flag_sets_its_rule_and_leaves_the_others(): + assert parse("--max-run", "2") == model.ShapeRules(max_run=2) + assert parse("--max-same-depth", "4") == model.ShapeRules(max_same_depth=4) + assert parse("--min-variation", "10") == model.ShapeRules(min_total_variation=10) + assert parse("--master-min-span", "4") == model.ShapeRules(master_min_span=4) + assert parse("--allow-monotone") == model.ShapeRules(forbid_monotone=False) + + +def test_off_turns_a_rule_off_and_is_not_the_same_as_not_given(): + assert parse("--max-run", "off") == model.ShapeRules(max_run=None) + assert parse("--master-min-span", "off").master_min_span is None + assert parse("--min-variation", "off").min_total_variation is None + + +def test_a_rule_no_key_could_meet_is_a_usage_error_naming_the_flag(capsys): + with pytest.raises(SystemExit) as exc: + parse("--master-min-span", "12") + assert exc.value.code == 2 + assert "--master-min-span is 12 but no key can span more than 9" in capsys.readouterr().err + with pytest.raises(SystemExit): + parse("--min-variation", "31") + assert "--min-variation is 31" in capsys.readouterr().err