diff --git a/README.md b/README.md index d83307a..cd612b1 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,11 @@ The reasons, the numbers behind the defaults and what each costs in key space ar - `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. +- `solve_system.py` follows them too, for every key it chooses (a master is a key that is in + the `masters` of any core). The system file sets them in an optional `shape` object, with + the rule names of the table above and `null` to turn one off (see + [System file format](#system-file-format)). +- `check_system` does not use the rules yet. ```bash ./gen_bittings.py --pins 7 -n 3 --master # three masters, no parity pattern @@ -142,6 +146,7 @@ A JSON file. Fields (only `keys` and `cores` are needed for the basics): | `keys` | `{name: bitting}` of operating keys, including decoded unit keys. | | `retired_keys` | `{name: bitting}` of old keys that must **not** operate any new core. | | `control_keys` | `{name: bitting}` of control keys. | +| `shape` | Optional object of [key shape rules](#key-shape-rules) for the keys the solver chooses: any of `max_run`, `max_same_depth`, `forbid_monotone`, `master_min_span` and `min_total_variation`, each a whole number of at least 1 (`forbid_monotone`: `true` or `false`), or `null` to turn the rule off. Rules left out keep their defaults, so a file without `shape` gets them all. An unknown rule name is an error, as is a rule no key of the key space could meet. | | `pinning` | Optional name of a pinning system (`"A2"`): opts in to the pinning checks. Without it the file is read exactly as before. | | `cores` | List of `{"name", "change", "masters"}`. `change` is a key name, a wildcard such as `"unit:*"`, or a list of those. `masters` is a list of key names pinned above the change key (may be empty). With `pinning`, each core also needs `"control"`: the name of its control key in `control_keys`. | | `retired_cores` | Optional, with `pinning`: how the old cores were pinned, as a list of `{"name", "change", "masters", "control"}` shaped like `cores`. `change` names keys in `keys` or `retired_keys` (a wildcard may match nothing yet), and `masters` and `control` name entries in `retired_keys`. | @@ -396,6 +401,15 @@ printed, and the printed pins are the ones the checker's own pinner computed. so look for that line (or `problem(s) flagged`) and do not use the result; run it again, since each run draws different candidates. + Every key it chooses also follows the [shape rules](#key-shape-rules) of the + file, and it never relaxes one: if no bitting for a key follows them (a thin + pinnable set can leave none, since pinning decides the cuts first), that key is + left `null` in the written file, a `NOT SOLVED` line says which rules turned + the draws down, and the full check is skipped because the file is not complete. + Change or turn off a rule in the `shape` object, or change a known key, and run it + again. With every rule off the solver chooses exactly what it did before the + rules existed. + If the file sets `pinning` (see [Pinning](#pinning-optional)), the solver also treats a core that cannot be pinned as a hard conflict, and adds the expected number of undecoded unit keys that cannot take the unit master and diff --git a/docs/design.md b/docs/design.md index 2b2aa59..1bc4674 100644 --- a/docs/design.md +++ b/docs/design.md @@ -141,6 +141,24 @@ The line comes before `Wrote ...` and again after the full check, so it is the l the output; the exit status is unchanged. A conflict between known keys alone does not trigger it: nothing the solver chooses can change that, and the check reports it (D59). +**Shape rules in the solver** (D64). Every key the solver chooses follows the file's shape +rules (`Config.shape`, a `model.ShapeRules`; master means a key in the `masters` of any +core). They filter the candidates and leave the score alone (D6). The single-key search draws +candidates with `shaped_candidates` and walks to neighbours that follow the rules; in the +pinning construction the draws from the exact pinnable set are filtered the same way, and a +set small enough to list is filtered whole. Each gives up after 20,000 draws the rules turned +down (and never fewer than 50 for each candidate asked for): the key is left `null` in the +file, a `NOT SOLVED` line gives the rule tally, and the full check, which would reject the +null, is skipped. Nothing is relaxed. A thin pinnable set (a unit master among many decoded +units) can leave no key that follows the rules; that is reported, not repaired. With every +rule off the same seeded draws are made as before the rules existed, so output is +byte-identical. The summary of chance cross-operation compares the chosen unit master with +typical ones; with rules on those are drawn once for each master from the keys that follow +them (reused in turn when a tight rule set leaves few, left out when it leaves none), never +by a separate bounded search for each sample. For keys built together, the `NOT SOLVED` +reason names a key only if it broke a rule in at least 80% of the draws (any random key +breaks one now and then); the others say they were built together with it. + ## The key space The rules that decide which bittings can be cut live in one frozen object, diff --git a/docs/designs/key-shape-rules.md b/docs/designs/key-shape-rules.md index 1448c1e..f4b61d9 100644 --- a/docs/designs/key-shape-rules.md +++ b/docs/designs/key-shape-rules.md @@ -123,7 +123,9 @@ only the 7% of bittings with three in a row and leaves the pairs, which is the c - **Never silently relaxed.** If no candidate passes (a tight system, or a pinnable set with nothing in it that has the right shape), the tool says which rule left nothing, and the key stays unsolved under the existing "NOT SOLVED" line (D59). Turning a rule off is - the system file's decision, not the tool's. + the system file's decision, not the tool's. For the solver, "unsolved" means the key is left + `null` in the written file (the file is not complete, so the full check is skipped), and the + NOT SOLVED line names the key and the rules, before `Wrote ...` and again last. - **Keys that already exist** are not changed. `check_system` and `check_bittings` print the ones that break a rule as an advisory `SHAPE` line, with positions only and never values. It does not change the exit status or the count of warnings. Decoded unit @@ -143,8 +145,9 @@ what the tools count alone. The risk figures are unchanged. ## What changes in each tool -- **Solver, one key at a time.** `random_candidate` and `neighbors` only produce keys that - pass, so the search never walks through a bad key. The score is unchanged (D6). +- **Solver, one key at a time.** The candidates and the neighbours it walks to only include + keys that pass, so the search never goes through a bad key. The score is unchanged (D6), and + with every rule off the same draws are made as before, so seeded output is byte-identical. - **Solver, pinning, keys built together** (D60). The pinnable set is built exactly and its draws are uniform; draws that fail the rules are thrown away and drawn again, up to a bound, and a set small enough to list is filtered whole. If the bound is reached with diff --git a/sfic_solver/solve_system.py b/sfic_solver/solve_system.py index 6fd665d..25e4382 100644 --- a/sfic_solver/solve_system.py +++ b/sfic_solver/solve_system.py @@ -26,11 +26,19 @@ weight of one (docs/designs/pinnable-solving.md). The undecoded keys are then taken from the retired cores when they describe the units. +Every key the solver chooses also follows the file's shape rules (the optional `shape` +object; by default no equal adjacent cuts, no depth used more than three times, no key +whose cuts only go one way, and a master spanning at least six depths). They filter the +candidates and are never relaxed: if no bitting for a key follows them, the key is left +null, the output says which rules turned the draws down, and the full check is skipped +(docs/designs/key-shape-rules.md). + Method: random search plus single-cut hill climbing, scoring each candidate exactly. Each key has only a few tens of thousands of valid bittings, so a few thousand trials per key is plenty. """ import argparse +import collections import json import os import random @@ -49,11 +57,69 @@ CLOSE_WEIGHT = 1_000.0 MAX_JOINT_KEYS = 3 # most unknown keys sharing cores that are built together: each # position lists up to 10 ** this digit combinations +MAX_SHAPE_REJECTS = 20_000 # draws turned down by the shape rules before a key is given up + # on (at least 50 for each candidate asked for) +SHAPE_OFF = model.ShapeRules(max_run=None, max_same_depth=None, forbid_monotone=False, + master_min_span=None, min_total_variation=None) UNPINNABLE_WEIGHT = 1.0 # one undecoded unit key that cannot take the master counts as # one expected cross-operation; with the scale of the two terms # it makes avoiding rekeyed unit cores the primary goal +class NoShapedBitting(Exception): + """No bitting that follows the shape rules was found; `rejected` counts, by rule, the + draws that broke one.""" + + def __init__(self, rejected): + super().__init__(shape_reason(rejected)) + self.rejected = rejected + + +MOST_DRAWS = 0.8 # a key that broke a rule in this share of the draws is the one to blame + + +def tally_broken(rejected, broken): + """Count a draw's broken (key, rule) pairs: each pair once, and the key itself once + (as (key, None)) however many rules it broke.""" + rejected.update(broken) + rejected.update({(k, None) for k, _ in broken}) + + +def group_reasons(group, rejected, draws): + """Why no combination for a group of keys built together follows the shape rules, from + the counts of the `draws` turned down (see tally_broken): one summary for the group and + one reason for each key. Only a key that broke a rule in most of the draws is blamed; a + key that did not says whose rules it waited for, since any random key breaks one now + and then.""" + tallies = {k: collections.Counter({rule: n for (key, rule), n in rejected.items() + if key == k and rule}) for k in group} + blamed = [k for k in group if draws and rejected.get((k, None), 0) >= MOST_DRAWS * draws] + shown = blamed or [k for k in group if tallies[k]] + if len(group) == 1: + summary = shape_reason(tallies[group[0]]) + elif shown: + summary = "draws broke " + "; ".join( + f"{k}: " + shape_reason(tallies[k]).removeprefix("draws broke ") for k in shown) + else: + summary = shape_reason(collections.Counter()) + reasons = {} + for k in group: + if blamed and k not in blamed: + reasons[k] = f"built together with {', '.join(blamed)}, which has none: {summary}" + elif len(group) == 1 or k in blamed: + reasons[k] = shape_reason(tallies[k]) + else: + reasons[k] = summary + return summary, reasons + + +def shape_reason(rejected): + """Which rules turned draws down, for a message.""" + if not rejected: + return "none of the bittings that could be drawn follows them" + return "draws broke " + ", ".join(f"{name} {count:,}" for name, count in rejected.most_common()) + + @lru_cache(maxsize=None) def cached_options(change, masters): return tuple(tuple(o) for o in model.options_for(change, masters)) @@ -78,6 +144,13 @@ def __init__(self, cfg): self.assign[name] = cuts self.cores = cfg.cores + # Shape rules: they apply to every key the solver chooses; a master is a key that is in + # the masters of any core. `no_shape` holds the keys for which no bitting was found. + self.shape = cfg.shape + self.shape_on = cfg.shape != SHAPE_OFF + self.masters = {m for core in cfg.cores for m in core["masters"]} + self.no_shape = {} # key -> why no bitting follows the shape rules + # Pinning: only for files that set it. The population of undecoded unit keys comes # from the retired cores when they describe the units, and `population_note` says # why not when they cannot be used (the checker prints the same line). @@ -137,20 +210,80 @@ def pinnable_set(self, group): return JointSet(allowed, self.space.max_step), allowed # -- candidate generation ------------------------------------------------- - def random_candidate(self, rng): - while True: - cuts = tuple(rng.choice(self.space.digits[p]) for p in range(self.space.pins)) - if self.space.macs_ok(cuts): - return cuts - - def neighbors(self, cuts): + def draw_cuts(self, rng): + return tuple(rng.choice(self.space.digits[p]) for p in range(self.space.pins)) + + def shape_ok(self, k, cuts): + return self.shape.ok(cuts, k in self.masters) + + def shaped_candidates(self, rng, k, n): + """Up to `n` random MACS-valid bittings for key `k` that follow the shape rules, and + the count, by rule, of the draws they turned down. Fewer than `n` (maybe none) if the + rules turn down almost everything.""" + found, rejected, rejects = [], collections.Counter(), 0 + limit = max(50 * n, MAX_SHAPE_REJECTS) + while len(found) < n: + cuts = self.draw_cuts(rng) + if not self.space.macs_ok(cuts): + continue + broken = self.shape.violations(cuts, k in self.masters) + if broken: + rejected.update(name for name, _ in broken) + rejects += 1 + if rejects > limit: + break + continue + found.append(cuts) + return found, rejected + + def random_candidate(self, rng, k=None): + """A random MACS-valid bitting; for key `k`, one that follows the shape rules too + (NoShapedBitting if none turns up).""" + if k is None: + while True: + cuts = self.draw_cuts(rng) + if self.space.macs_ok(cuts): + return cuts + found, rejected = self.shaped_candidates(rng, k, 1) + if not found: + raise NoShapedBitting(rejected) + return found[0] + + def neighbors(self, cuts, k=None): + """The MACS-valid bittings one cut away; for key `k`, only those that follow the shape + rules.""" for p in range(self.space.pins): for d in self.space.digits[p]: if d != cuts[p]: cand = cuts[:p] + (d,) + cuts[p + 1:] - if self.space.macs_ok(cand): + if self.space.macs_ok(cand) and (k is None or self.shape_ok(k, cand)): yield cand + def joint_broken(self, group, cand): + """The shape rules broken by the keys of a group, as (key, rule) pairs, for a drawn + digit-tuple list `cand` (see joint.bittings).""" + return [(k, name) for k, cuts in zip(group, bittings(cand)) + for name, _ in self.shape.violations(cuts, k in self.masters)] + + def shaped_draws(self, pinnable, group, rng, trials): + """Up to `trials` draws from the pinnable set whose keys all follow the shape rules, the + count of what the turned-down draws broke (see tally_broken), and how many draws were + made.""" + chosen, rejected, rejects, made = [], collections.Counter(), 0, 0 + limit = max(50 * trials, MAX_SHAPE_REJECTS) + while len(chosen) < trials: + cand = pinnable.draw(rng) + made += 1 + broken = self.joint_broken(group, cand) + if broken: + tally_broken(rejected, broken) + rejects += 1 + if rejects > limit: + break + continue + chosen.append(cand) + return chosen, rejected, made + # -- scoring (only terms that depend on key k) ----------------------------- def expected_unknown_conflicts(self, core): if not self.unit_count: @@ -250,15 +383,33 @@ def solve_jointly(prob, group, trials, rng, report): prob.impossible.append((group, reason)) print(f" {names}: NO bitting can be pinned with the known keys: {reason}") return False + rejected = collections.Counter() if pinnable.count <= trials: - chosen, how = list(pinnable.enumerate()), "scored all of them" + every = list(pinnable.enumerate()) + draws = len(every) + chosen = [] + for cand in every: + broken = prob.joint_broken(group, cand) + if broken: + tally_broken(rejected, broken) + else: + chosen.append(cand) + how = ("scored all of them" if len(chosen) == len(every) + else f"{len(chosen):,} follow the shape rules; scored all of those") else: - chosen = list(dict((tuple(c), c) for c in (pinnable.draw(rng) for _ in range(trials))) - .values()) + chosen, rejected, draws = prob.shaped_draws(pinnable, group, rng, trials) + chosen = list(dict((tuple(c), c) for c in chosen).values()) how = f"scored {len(chosen)} drawn from them" + if prob.shape_on: + how += " that follow the shape rules" noun = "bitting" if len(group) == 1 else "combination" print(f" {names}: {pinnable.count:,} pinnable {noun}{'' if pinnable.count == 1 else 's'} " f"with the known keys; {how}") + if not chosen: + summary, reasons = group_reasons(group, rejected, draws) + prob.no_shape.update(reasons) + print(f" {names}: NO pinnable {noun} follows the shape rules ({summary})") + return True scored = [] for i, cand in enumerate(chosen): for k, cuts in zip(group, bittings(cand)): @@ -288,8 +439,13 @@ def solve(prob, trials, sweeps, rng): for k in prob.unknown: if k in handled: continue - scored = [(prob.score_key(k, c), c) for c in - (prob.random_candidate(rng) for _ in range(trials))] + candidates, rejected = prob.shaped_candidates(rng, k, trials) + if not candidates: + prob.no_shape[k] = shape_reason(rejected) + print(f" {k}: NO bitting found that follows the shape rules " + f"({prob.no_shape[k]})") + continue + scored = [(prob.score_key(k, c), c) for c in candidates] scored.sort() best_score, best = scored[0] clean = sum(1 for sc, _ in scored if sc < HARD) / len(scored) @@ -300,14 +456,13 @@ def solve(prob, trials, sweeps, rng): for sweep in range(sweeps): improved = False - order = prob.unknown[:] + order = [k for k in prob.unknown if k not in prob.no_shape] rng.shuffle(order) for k in order: cur = prob.assign[k] cur_score = prob.score_key(k, cur) best_score, best = cur_score, cur - pool = list(prob.neighbors(cur)) + [prob.random_candidate(rng) - for _ in range(trials // 2)] + pool = list(prob.neighbors(cur, k)) + prob.shaped_candidates(rng, k, trials // 2)[0] for cand in pool: s = prob.score_key(k, cand) if s < best_score - 1e-9: @@ -331,6 +486,8 @@ def failed_keys(prob): none_exist = {k for group, _ in prob.impossible for k in group} failed = [] for k in prob.unknown: + if k in prob.no_shape: + continue hard = int(prob.score_key(k, prob.assign[k]) // HARD) if hard and k not in none_exist: failed.append((k, hard)) @@ -342,6 +499,10 @@ def not_solved_lines(prob, check="below"): full check of the result is, for the lines to point at: they are printed before it and again after it.""" lines = [] + for k, reason in prob.no_shape.items(): + lines.append(f"NOT SOLVED: no bitting for {k} follows the shape rules ({reason}), so " + f"{k} is left null and running it again will not help; change or turn off " + f"a rule in the `shape` object of the system file.") for group, reason in prob.impossible: lines.append(f"NOT SOLVED: no bitting for {', '.join(group)} can be pinned with the " f"known keys ({reason}), so running it again will not help; a known key " @@ -360,13 +521,24 @@ def unit_pair_summary(prob, rng, samples=300): if not core["is_unit"] or not prob.unit_count: continue unknown_masters = [m for m in core["masters"] if m in prob.unknown] - if not unknown_masters: + if not unknown_masters or any(m not in prob.assign for m in core["masters"]): continue chosen = prob.space.pair_conflict_probability([prob.assign[m] for m in core["masters"]], prob.population) + # With shape rules on, the typical master is drawn from the keys that follow them, found + # once for each master (a tight rule set can leave few: they are reused in turn) and + # not by 300 separate bounded searches; if there are none the summary is left out. + # With every rule off the draws are the unfiltered ones, as they always were. + pools = {} + if prob.shape_on: + for m in unknown_masters: + pools[m] = prob.shaped_candidates(rng, m, samples)[0] + if not all(pools.values()): + continue total = cannot_total = 0.0 - for _ in range(samples): - masters = [prob.random_candidate(rng) if m in unknown_masters else prob.assign[m] + for i in range(samples): + masters = [(pools[m][i % len(pools[m])] if m in pools else prob.random_candidate(rng)) + if m in unknown_masters else prob.assign[m] for m in core["masters"]] total += prob.space.pair_conflict_probability(masters, prob.population) if prob.pinning: @@ -417,16 +589,21 @@ def main(argv=None): raw = cfg.raw for label in ("keys", "control_keys"): for name in raw.get(label, {}): - if name in prob.unknown: + if name in prob.assign and name in prob.unknown: raw[label][name] = "".join(map(str, prob.assign[name])) out = args.out or os.path.splitext(args.config)[0] + ".solved.json" with open(out, "w") as f: json.dump(raw, f, indent=2) for line in not_solved_lines(prob): print(f"\n{line}") - print(f"\nWrote {out}. Full check of the result:\n") - sys.stdout.flush() - check_system.main([out]) # report only; its exit status is not ours (as before) + if prob.no_shape: + print(f"\nWrote {out}. The full check is skipped: {', '.join(prob.no_shape)} still " + f"{'has' if len(prob.no_shape) == 1 else 'have'} no bitting, so the file is not " + f"complete.") + else: + print(f"\nWrote {out}. Full check of the result:\n") + sys.stdout.flush() + check_system.main([out]) # report only; its exit status is not ours (as before) for line in not_solved_lines(prob, "above"): print(f"\n{line}") return 0 # exit status 0 whenever a result was written (README); the verdict is the line diff --git a/tests/test_solve_system.py b/tests/test_solve_system.py index 781f531..ac87905 100644 --- a/tests/test_solve_system.py +++ b/tests/test_solve_system.py @@ -1,4 +1,6 @@ """solve_system: fills null bittings without touching known keys.""" +import collections +import itertools import json import random import re @@ -9,6 +11,8 @@ from conftest import call_main, run_script SECTIONS = ("keys", "retired_keys", "control_keys") +RULES_OFF = {name: None for name in ("max_run", "max_same_depth", "forbid_monotone", + "master_min_span", "min_total_variation")} @pytest.fixture @@ -386,6 +390,7 @@ def decoded_units(prob, count, seed=1): def test_with_few_pinnable_masters_the_solver_scores_every_one_of_them( pinned, write_cfg, tmp_path): pinned["keys"].update(decoded_units(problem(pinned), 7)) + pinned["shape"] = RULES_OFF # this test is about listing them all; see the rules below prob = problem(pinned) pinnable, _ = prob.pinnable_set(["unit_master"]) # Count them without the construction: every bitting whose cuts can all be pinned. @@ -450,3 +455,308 @@ def test_without_pinning_no_group_is_built_and_the_search_is_the_old_one(unsolve proc, _ = solve(write_cfg, unsolved, tmp_path, seed=1) assert "pinnable" not in proc.stdout assert "of random candidates were free of hard conflicts" in proc.stdout + + +# -- shape rules (docs/designs/key-shape-rules.md) ------------------------------------ + +def shape_breaks(key, master=False): + """What a key breaks of the default shape rules, by plain loops, so the tests do not + lean on ShapeRules.""" + cuts = [int(c) for c in key] + broken = [] + if any(a == b for a, b in zip(cuts, cuts[1:])): + broken.append("equal neighbours") + if max(cuts.count(d) for d in set(cuts)) > 3: + broken.append("a depth over three times") + steps = [b - a for a, b in zip(cuts, cuts[1:])] + if all(s >= 0 for s in steps) or all(s <= 0 for s in steps): + broken.append("one way") + if master and max(cuts) - min(cuts) < 6: + broken.append("narrow master") + return broken + + +def solved_keys(raw, names): + return {n: (raw["keys"].get(n) or raw["control_keys"].get(n)) for n in names} + + +@pytest.mark.parametrize("seed", [1, 2, 3]) +def test_solved_keys_follow_the_shape_rules(unsolved, write_cfg, tmp_path, seed): + proc, out = solve(write_cfg, unsolved, tmp_path, seed=seed) + assert proc.returncode == 0, proc.stderr + got = solved_keys(json.loads(out.read_text()), ["unit_master", "control_common"]) + assert shape_breaks(got["unit_master"], master=True) == [] # a master of the unit cores + assert shape_breaks(got["control_common"]) == [] + assert "NOT SOLVED" not in proc.stdout + + +def test_a_whole_system_from_scratch_follows_the_shape_rules(clean_cfg, write_cfg, tmp_path): + for section in SECTIONS: + for name in clean_cfg.get(section, {}): + if section != "retired_keys" and not name.startswith("unit:"): + clean_cfg[section][name] = None + proc, out = solve(write_cfg, clean_cfg, tmp_path) + solved = json.loads(out.read_text()) + masters = {"general_master", "unit_master"} + for name in ("general_master", "key_a", "key_b", "key_c", "unit_master", "control_common"): + assert shape_breaks(solved_keys(solved, [name])[name], name in masters) == [], name + + +def test_pinning_solutions_with_keys_built_together_follow_the_shape_rules( + pinned, write_cfg, tmp_path): + for name in ("area_a", "area_b", "master_sub"): + pinned["keys"][name] = None # one group of three keys + pinned["keys"]["area_c"] = None + proc, out = solve(write_cfg, pinned, tmp_path, seed=2) + assert proc.returncode == 0, proc.stderr + assert "that follow the shape rules" in proc.stdout + solved = json.loads(out.read_text()) + for name in ("area_a", "area_b", "area_c", "master_sub", "unit_master"): + masters = {"master_sub", "unit_master", "master_top"} + assert shape_breaks(solved["keys"][name], name in masters) == [], name + + +# Recorded from the solver before the shape rules (D64, step 4), with every rule off: the +# seeded results of the "unsolved" system must not change when the rules are turned off. +BEFORE_THE_RULES = {1: ("8961038", "0165210"), 2: ("8961038", "0101630"), + 3: ("8989410", "0105030")} + + +@pytest.mark.parametrize("seed", [1, 2, 3]) +def test_with_every_rule_off_seeded_results_are_what_they_were( + unsolved, write_cfg, tmp_path, seed): + unsolved["shape"] = RULES_OFF + proc, out = solve(write_cfg, unsolved, tmp_path, seed=seed) + assert proc.returncode == 0, proc.stderr + solved = json.loads(out.read_text()) + assert (solved["keys"]["unit_master"], solved["control_keys"]["control_common"]) \ + == BEFORE_THE_RULES[seed] + assert "shape rules" not in proc.stdout + + +def test_the_file_s_shape_settings_are_used(unsolved): + free = {**unsolved, "pattern": None} # alternating parity cannot have equal neighbours + default = problem(free) + pairs = problem({**free, "shape": {"max_run": 2}}) + rng = random.Random(4) + runs = lambda cands: {max(len(list(g)) for _, g in itertools.groupby(c)) for c in cands} + assert runs(default.shaped_candidates(rng, "key_c", 400)[0]) == {1} + assert runs(pairs.shaped_candidates(rng, "key_c", 400)[0]) == {1, 2} + + +def test_the_span_rule_is_for_the_keys_that_are_masters(unsolved): + prob = problem(unsolved) + assert "unit_master" in prob.masters and "general_master" in prob.masters + assert "key_a" not in prob.masters and "control_common" not in prob.masters + rng = random.Random(1) + assert all(max(c) - min(c) >= 6 for c in prob.shaped_candidates(rng, "unit_master", 200)[0]) + assert any(max(c) - min(c) < 6 for c in prob.shaped_candidates(rng, "key_a", 200)[0]) + + +def test_neighbors_and_random_candidates_for_a_key_follow_the_shape_rules(unsolved): + prob = problem({**unsolved, "pattern": None}) + start = prob.random_candidate(random.Random(2), "unit_master") + assert shape_breaks("".join(map(str, start)), master=True) == [] + near = list(prob.neighbors(start, "unit_master")) + assert near and all(not shape_breaks("".join(map(str, c)), master=True) for c in near) + assert len(list(prob.neighbors(start))) > len(near) # without a key: not filtered + + +def test_a_key_no_bitting_can_be_for_raises_with_the_rules_that_turned_draws_down(unsolved): + unsolved["shape"] = {"min_total_variation": 30} # only 0,5,0,5... reach it + prob = problem(unsolved) + with pytest.raises(solve_system.NoShapedBitting, match="max_same_depth"): + prob.random_candidate(random.Random(1), "key_a") + assert prob.shaped_candidates(random.Random(1), "key_a", 5)[0] == [] + + +# Three pins and a master: no key of three cuts spans six unless it only goes one way. +@pytest.fixture +def three_pin_master(): + return {"pins": 3, "keys": {"a": "135", "m": None}, + "cores": [{"name": "x", "change": "a", "masters": ["m"]}]} + + +def test_a_key_with_no_bitting_that_follows_the_rules_is_left_null_and_said_so( + three_pin_master, write_cfg, tmp_path): + proc, out = solve(write_cfg, three_pin_master, tmp_path) + assert proc.returncode == 0 and "Traceback" not in proc.stderr + assert json.loads(out.read_text())["keys"]["m"] is None + assert "m: NO bitting found that follows the shape rules (draws broke" in proc.stdout + assert "master_min_span" in proc.stdout + assert "The full check is skipped: m still has no bitting" in proc.stdout + lines = proc.stdout.strip().splitlines() + assert lines[-1].startswith("NOT SOLVED: no bitting for m follows the shape rules") + assert "left null and running it again will not help" in lines[-1] + assert sum(line.startswith("NOT SOLVED") for line in lines) == 2 # before Wrote, and last + + +def test_relaxing_the_rule_in_the_file_lets_the_same_system_solve( + three_pin_master, write_cfg, tmp_path): + three_pin_master["shape"] = {"master_min_span": 4} + proc, out = solve(write_cfg, three_pin_master, tmp_path) + got = json.loads(out.read_text())["keys"]["m"] + assert got and len(got) == 3 and max(map(int, got)) - min(map(int, got)) >= 4 + assert "NOT SOLVED" not in proc.stdout and "skipped" not in proc.stdout + + +def test_pinned_keys_with_no_shaped_bitting_are_reported_as_that(pinned, write_cfg, tmp_path): + """The bound allows 30, but only keys that alternate two depths reach it, and they use one + of them four times.""" + pinned["keys"].update({"master_sub": "7305496", "area_a": "5721276", "area_b": "9565698", + "area_c": "3323872", "unit_master": "7587672"}) + pinned["keys"]["area_d"] = None + pinned["shape"] = {"min_total_variation": 30} + proc, out = solve(write_cfg, pinned, tmp_path) + assert proc.returncode == 0 and "Traceback" not in proc.stderr + assert "area_d: NO pinnable bitting follows the shape rules (draws broke" in proc.stdout + assert json.loads(out.read_text())["keys"]["area_d"] is None + assert proc.stdout.strip().splitlines()[-1].startswith( + "NOT SOLVED: no bitting for area_d follows the shape rules") + + +def seven_decoded_units(pinned): + """The system of test_with_few_pinnable_masters...: 24 pinnable unit masters, all of which + end in two equal cuts and half of which span too few depths.""" + pinned["keys"].update(decoded_units(problem(pinned), 7)) + return pinned + + +def test_a_thin_pinnable_set_that_the_shape_rules_empty_is_reported_not_relaxed( + pinned, write_cfg, tmp_path): + seven_decoded_units(pinned) + proc, out = solve(write_cfg, pinned, tmp_path, seed=3) + assert proc.returncode == 0 and "Traceback" not in proc.stderr + assert ("unit_master: 24 pinnable bittings with the known keys; scored all of them" + not in proc.stdout) + assert "unit_master: NO pinnable bitting follows the shape rules (draws broke max_run 24" \ + in proc.stdout + assert json.loads(out.read_text())["keys"]["unit_master"] is None + assert proc.stdout.strip().splitlines()[-1].startswith( + "NOT SOLVED: no bitting for unit_master follows the shape rules") + + +def test_a_thin_pinnable_set_is_listed_and_filtered_whole(pinned, write_cfg, tmp_path): + seven_decoded_units(pinned) + pinned["shape"] = {"max_run": None} # leaves the depth and span rules + prob = problem(pinned) + pinnable, _ = prob.pinnable_set(["unit_master"]) + follow = sum(not prob.joint_broken(["unit_master"], c) for c in pinnable.enumerate()) + assert 0 < follow < pinnable.count == 24 + proc, out = solve(write_cfg, pinned, tmp_path, seed=3) + assert (f"unit_master: 24 pinnable bittings with the known keys; {follow} follow the shape " + f"rules; scored all of those") in proc.stdout + got = json.loads(out.read_text())["keys"]["unit_master"] + assert got and set(shape_breaks(got, master=True)) <= {"equal neighbours"} # the rule left off + assert "NOT SOLVED" not in proc.stdout + + +# -- review of #38: the middle band between easy rules and impossible ones ------------------- + +@pytest.fixture +def tight_rules(unsolved): + """About 0.03% of MACS-valid bittings pass these, so a key exists but 300 draws of a + summary would each need thousands of tries.""" + unsolved["shape"] = {"master_min_span": 9, "min_total_variation": 26} + return unsolved + + +@pytest.mark.parametrize("seed", [1, 2, 3]) +def test_tight_but_satisfiable_rules_solve_and_do_not_crash_the_summary( + tight_rules, write_cfg, tmp_path, seed): + proc, out = solve(write_cfg, tight_rules, tmp_path, seed=seed) + assert proc.returncode == 0 and "Traceback" not in proc.stderr, proc.stderr + got = json.loads(out.read_text())["keys"]["unit_master"] + cuts = [int(c) for c in got] + assert max(cuts) - min(cuts) >= 9 and sum(abs(a - b) for a, b in zip(cuts, cuts[1:])) >= 26 + assert "NOT SOLVED" not in proc.stdout and proc.stdout.strip().endswith("OK") + + +def test_the_summary_is_left_out_when_no_typical_master_follows_the_rules( + unsolved, capsys, monkeypatch): + prob = problem(unsolved) + prob.assign["unit_master"] = tuple(map(int, "8765432")) + monkeypatch.setattr(solve_system.Problem, "shaped_candidates", + lambda self, rng, k, n: ([], {})) + solve_system.unit_pair_summary(prob, random.Random(1), samples=5) + assert capsys.readouterr().out == "" + + +def test_the_summary_reuses_the_typical_masters_it_found(unsolved, capsys, monkeypatch): + prob = problem(unsolved) + prob.assign["unit_master"] = tuple(map(int, "8765432")) + few = [tuple(map(int, key)) for key in ("0549494", "9450505")] + monkeypatch.setattr(solve_system.Problem, "shaped_candidates", + lambda self, rng, k, n: (few, {})) + solve_system.unit_pair_summary(prob, random.Random(1), samples=7) + assert "Unit-to-unit cross-operation by chance" in capsys.readouterr().out + + +def test_with_every_rule_off_the_summary_draws_what_it_always_drew(unsolved, monkeypatch): + """The unfiltered draws, in the old order, so seeded output is unchanged (D64).""" + unsolved["shape"] = RULES_OFF + prob = problem(unsolved) + prob.assign["unit_master"] = tuple(map(int, "8765432")) + calls = [] + real = solve_system.Problem.random_candidate + monkeypatch.setattr(solve_system.Problem, "random_candidate", + lambda self, rng, k=None: calls.append(k) or real(self, rng, k)) + monkeypatch.setattr(solve_system.Problem, "shaped_candidates", + lambda *a: pytest.fail("no filtering with every rule off")) + solve_system.unit_pair_summary(prob, random.Random(1), samples=4) + assert calls == [None] * 4 + + +def test_a_group_blames_the_key_that_broke_the_rules_in_most_draws_not_one_that_did_by_chance( + pinned, write_cfg, tmp_path): + """unit_master has no pinnable bitting that follows the rules; control_b breaks one in + about half the draws, as any random key does, and has plenty of shaped bittings.""" + seven_decoded_units(pinned) + pinned["control_keys"]["control_b"] = None + proc, out = solve(write_cfg, pinned, tmp_path, seed=3) + assert proc.returncode == 0 and "Traceback" not in proc.stderr + assert ("unit_master, control_b: NO pinnable combination follows the shape rules " + "(draws broke unit_master: max_run 20,001") in proc.stdout + lines = [line for line in proc.stdout.splitlines() if line.startswith("NOT SOLVED")] + master = next(line for line in lines if "for unit_master" in line) + waiting = next(line for line in lines if "for control_b" in line) + assert "(draws broke max_run 20,001" in master and "built together" not in master + assert ("(built together with unit_master, which has none: draws broke unit_master: " + in waiting) + assert "control_b:" not in waiting.split("which has none")[1] # it is not blamed + + +def tallied(*draws): + """The tally of draws, each a list of (key, rule) pairs, as the solver keeps it.""" + rejected = collections.Counter() + for broken in draws: + solve_system.tally_broken(rejected, broken) + return rejected + + +def test_group_reasons_for_one_key_are_the_plain_tally(): + rejected = tallied([("k", "max_run"), ("k", "master_min_span")], [("k", "max_run")]) + summary, reasons = solve_system.group_reasons(["k"], rejected, 2) + assert summary == reasons["k"] == "draws broke max_run 2, master_min_span 1" + + +def test_group_reasons_blame_only_keys_that_broke_a_rule_in_most_draws(): + draws = [[("a", "max_run"), ("b", "max_run")]] * 4 + [[("a", "forbid_monotone")]] * 5 \ + + [[("b", "max_same_depth")]] + summary, reasons = solve_system.group_reasons(["a", "b"], tallied(*draws), 10) + assert summary == "draws broke a: forbid_monotone 5, max_run 4" # b: 5 of 10 draws + assert reasons["a"] == "draws broke forbid_monotone 5, max_run 4" + assert reasons["b"] == f"built together with a, which has none: {summary}" + + +def test_group_reasons_with_no_key_to_blame_give_the_group_the_whole_picture(): + draws = [[("a", "max_run")]] * 5 + [[("b", "max_run")]] * 5 + summary, reasons = solve_system.group_reasons(["a", "b"], tallied(*draws), 10) + assert summary == "draws broke a: max_run 5; b: max_run 5" + assert reasons == {"a": summary, "b": summary} + + +def test_group_reasons_when_the_draws_broke_nothing_say_none_could_be_drawn(): + summary, reasons = solve_system.group_reasons(["a", "b"], collections.Counter(), 0) + assert summary == reasons["a"] == reasons["b"] == \ + "none of the bittings that could be drawn follows them"