diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64fa0f6..c2ac2db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: llms.txt server.json tool.json \ charactercheck/source.py charactercheck/registry.py \ charactercheck/ddb_registry.py charactercheck/ddb-config-registry.json \ - ECOSYSTEM_CONFORMANCE.md \ + ECOSYSTEM_CONFORMANCE.md docs/human-differential-testing.md \ tests/test_input_contract.py tests/test_snapshot.py tests/fixtures-manifest.json do grep -Eq "/${required}$" /tmp/charactercheck-sdist-files.txt || { diff --git a/CHANGELOG.md b/CHANGELOG.md index 906d163..8e39121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased + +- Convert a second round of reversible live D&D Beyond observations into + fail-closed handlers for base darkvision and walking speed, magical-sleep + immunity, Charmed-save advantage, spell-group healing bonuses, structural + subclass markers, and selected-option ability-based skill bonuses. +- Preserve only the mechanical join for explicitly selected option definitions; + inactive builder catalogs and presentation metadata remain trust-neutral, + while novel selected-option mechanics retain scoped or global uncertainty. +- Prevent individual weapon records from repeating a printed Sap/Push/etc. + property as though it were a learned character mastery. +- Add a privacy-gated A → B → A human differential protocol and a proposed + cross-character corpus for gathering reproducible schema evidence without + treating one character or one UI observation as a universal rule. + ## 0.7.0 - 2026-08-02 - Add a deterministic, value-free `table.evaluation/1.0` projection for diff --git a/MANIFEST.in b/MANIFEST.in index acbf119..e110d9f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -14,6 +14,7 @@ include llms.txt include server.json include tool.json recursive-include charactercheck *.json *.py +recursive-include docs *.md recursive-include examples *.json recursive-include tests *.json *.py global-exclude __pycache__ *.py[cod] diff --git a/README.md b/README.md index 4b0ba30..da0192f 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,8 @@ the relationship as `indeterminate`; an exact identical snapshot can still be persona/non-mechanical delta are `mechanically_unchanged`, not `unchanged`. A changed mechanical revision that reaches no classifier also falls back to `$`. Diff reports candidates or uncertainty only and never applies a change. +For controlled D&D Beyond UI research, use the privacy and reversal gates in +[the A → B → A human differential protocol](docs/human-differential-testing.md). ## Trust and canonical field semantics @@ -270,6 +272,10 @@ than treating an aggregate state as proof of completeness. See recovers selected skills, languages, and standard tools even when D&D Beyond leaves the modifier's `isGranted` flag false. Builder labels and unselected option catalogs are not retained. +- Reviewed direct facts now include species walking speed and darkvision, + magical-sleep immunity, the closed Charmed-save condition, and numeric + spell-group healing bonuses. They retain handler/source provenance and stay + non-trusted whenever separate omitted source mechanics reach their family. - Armor and shield AC requires `equipped: true`. `combat.weapons` remains an inventory view; `combat.active_attacks` is the action-facing view and includes the 2024 Unarmed Strike. A weapon mastery property is reported diff --git a/charactercheck/engine.py b/charactercheck/engine.py index 0e98fff..a4991bd 100644 --- a/charactercheck/engine.py +++ b/charactercheck/engine.py @@ -57,13 +57,7 @@ "proficiency:calligraphers-supplies": (["skills"], "tool proficiency"), "set-base:darkvision": (["senses"], "darkvision distance"), "set:innate-speed-walking": (["speeds"], "walking speed"), - "set:subclass": ([ - "abilities", "ac", "initiative", "hp", "saves", "skills", - "attacks", "weapons", "speeds", "senses", "defenses", - "languages", "proficiency_bonus", "spellcasting", "spell_save_dc", - "spell_attack_bonus", "spell_output", "spell_slots", - "prepared_spells", "resources", - ], "subclass selection can affect the static class build"), + "set:subclass": ([], "structural subclass-selection marker"), } MAXIMAL = ["unknown — treat all derived values as unverified"] @@ -432,7 +426,9 @@ def carried(it): for m in mods: st = (m.get("subType") or "").lower() if m.get("type") == "bonus" and st in SKILLS: - skill_bonus[st] = skill_bonus.get(st, 0) + (m.get("value") or 0) + value = m.get("value") + bonus = value if type(value) is int else am[ABIL[m["statId"]]] + skill_bonus[st] = skill_bonus.get(st, 0) + bonus cv_skill_bon, cv_skill_prof, cv_skill_abil = {}, {}, {} for c in cv: try: @@ -462,6 +458,47 @@ def skill(n): W.update(profs=profs, expertise=expertise, halfprof=halfprof, skill=skill) + # ---- directly encoded movement/defense/conditional modifier facts. + # These are source observations, not encounter adjudication. Closed codes + # keep upstream prose outside agent/snapshot outputs. + normal_speeds = copy.deepcopy( + (((d.get("race") or {}).get("weightSpeeds") or {}).get("normal") or {})) + W["speeds"] = { + key: value for key, value in normal_speeds.items() + if key in ("walk", "fly", "swim", "climb", "burrow") + and type(value) is int and value >= 0 + } + W["save_advantages"] = [] + W["defenses"] = {"immunities": []} + W["spell_output_modifiers"] = [] + for modifier in mods: + handler_id = modifier.get("_handler_id") + provenance = { + "handler_id": handler_id, + "source_bucket": modifier.get("_source_bucket"), + "component_id": modifier.get("componentId"), + } + if handler_id == "speed.walking.base": + W["speeds"]["walk"] = modifier["value"] + elif handler_id == "save.advantage.charmed": + W["save_advantages"].append({ + "condition": "charmed", + "timing": "avoid_or_end", + "scope": "all_saving_throws", + "provenance": provenance, + }) + elif handler_id == "defense.magical-sleep": + W["defenses"]["immunities"].append({ + "effect": "magical_sleep", + "provenance": provenance, + }) + elif handler_id == "spell.healing.bonus": + W["spell_output_modifiers"].append({ + "spell_group": "healing", + "bonus": modifier["value"], + "provenance": provenance, + }) + # ---- initiative init_bonus = sum(m.get("value") or 0 for m in mods if m.get("type") == "bonus" and m.get("subType") == "initiative") @@ -607,7 +644,10 @@ def acof(it): "attack_provenance": f"{why} {use:+d} + PB {pb}", "damage": f"{dice}{use:+d}", "damage_type": de.get("damageType"), - "properties": sorted(props), "mastery": sorted(ms), + # A mastery property printed on a weapon is not evidence that this + # character learned that mastery. Keep the observed property in + # ``properties``/``mastery_properties_on_weapons`` only. + "properties": sorted(props), "mastery": [], "offhand_label": bool(re.search(r"off.?hand", cname.get(str(it["id"]), ""), re.I)), "two_handed": "Two-Handed" in props, "light": "Light" in props, "loading": "Loading" in props}) @@ -1065,11 +1105,18 @@ def add(field_id, value, family=None, **kwargs): add(f"saves.{ability}.bonus", values.get("bonus"), "saves") add(f"saves.{ability}.proficient", values.get("proficient"), "saves", authority="source") + add("saves.conditional_advantages", + ((report.get("save_modifiers") or {}).get("conditional_advantages")), + "saves", authority="source") for skill, values in (report.get("skills") or {}).items(): add(f"skills.{skill}.bonus", values.get("bonus"), "skills") add(f"skills.{skill}.proficiency", values.get("proficiency"), "skills") add("senses.vision", ((report.get("senses") or {}).get("vision")), "senses", authority="source") + add("movement.speeds", report.get("speeds"), "speeds", + authority="source") + add("defenses", report.get("defenses"), "defenses", + authority="source") combat = report.get("combat") or {} ac = combat.get("ac") or {} @@ -1098,6 +1145,7 @@ def add(field_id, value, family=None, **kwargs): "spellcasting.ability": "spellcasting", "spellcasting.save_dc": "spell_save_dc", "spellcasting.attack_bonus": "spell_attack_bonus", + "spellcasting.output_modifiers": "spell_output", "spellcasting.prepared": "prepared_spells", "spellcasting.slots.maximum": "spell_slots", "spellcasting.slots.current": "spell_slots", @@ -1115,6 +1163,8 @@ def add(field_id, value, family=None, **kwargs): add("spellcasting.attack_bonus", spell.get("attack_bonus"), "spell_attack_bonus", formula=spell.get("provenance")) add("spellcasting.prepared", spell.get("prepared"), "prepared_spells") + add("spellcasting.output_modifiers", spell.get("output_modifiers"), + "spell_output", authority="source") add("spellcasting.slots.maximum", spell.get("slots_max"), "spell_slots") slot_state, slot_findings = _family_assessment(trust, "spell_slots") if slot_state == "trusted": @@ -1242,7 +1292,10 @@ def public_unhandled(record): ("wis", "wisdom"), ("cha", "charisma")]}, "skills": {n: {"bonus": W["skill"](n)[0], "proficiency": W["skill"](n)[1]} for n in sorted(SKILLS)}, + "save_modifiers": {"conditional_advantages": W["save_advantages"]}, + "speeds": W["speeds"], "senses": {"vision": vision(d)}, + "defenses": W["defenses"], "combat": { "ac": {"value": W["ac"], "provenance": W["ac_prov"]}, "initiative": {"bonus": W["init"], "provenance": W["init_prov"]}, @@ -1259,6 +1312,7 @@ def public_unhandled(record): "provenance": W["spell"]["provenance"], "cantrips": W["cantrips"], "prepared": W["prepared"], "spell_profiles": W["spell_profiles"], + "output_modifiers": W["spell_output_modifiers"], "slots_max": W["slots"], "slots_current": W["slots_cur"]} if W["spell"] else None), "resources": W["resources"], @@ -1951,7 +2005,8 @@ def vision(d): # never an independent feature-recognition path. # as arithmetic. Raw modifiers are never a second recognition path. for modifier in registry.classify_modifiers(d)["applied"]: - if modifier.get("_handler_id") == "sense.darkvision": + if modifier.get("_handler_id") in ( + "sense.darkvision", "sense.darkvision.base"): out.append({"feature": "Darkvision", "range_ft": modifier.get("value"), "provenance": f"modifier ({modifier.get('_source_bucket')})"}) @@ -1978,6 +2033,10 @@ def seatpack_data(d, r, *, for_dm=False, include_persona=False): "saves": r.get("saves"), "skills": sk, "passives": passives, + "save_modifiers": r.get("save_modifiers"), + "speeds": r.get("speeds"), + "senses": r.get("senses"), + "defenses": r.get("defenses"), "combat": r.get("combat"), "spellcasting": r.get("spellcasting"), "resources": r.get("resources"), diff --git a/charactercheck/registry.py b/charactercheck/registry.py index b4a47a2..5b7dd8d 100644 --- a/charactercheck/registry.py +++ b/charactercheck/registry.py @@ -45,7 +45,9 @@ class HandlerSpec: affects: tuple mode: str = "apply" requires_number: bool = False + accepts_stat_id: bool = False restrictions_supported: bool = False + restriction_codes: tuple = () rules_profiles: tuple = ("srd-5.2.1-partial",) @@ -66,7 +68,8 @@ def _spec(handler_id, type_name, subtype, affects, **kwargs): ["attacks", "weapons"])) for skill in SKILLS: HANDLERS.append(_spec(f"skill.bonus.{skill}", "bonus", skill, - ["skills"], requires_number=True)) + ["skills"], requires_number=True, + accepts_stat_id=True)) HANDLERS.append(_spec(f"skill.expertise.{skill}", "expertise", skill, ["skills"])) HANDLERS.append(_spec(f"skill.half.{skill}", "half-proficiency", skill, @@ -88,6 +91,19 @@ def _spec(handler_id, type_name, subtype, affects, **kwargs): requires_number=True), _spec("sense.darkvision", "sense", "darkvision", ["senses"], requires_number=True, mode="pass_through"), + _spec("sense.darkvision.base", "set-base", "darkvision", ["senses"], + requires_number=True, mode="pass_through"), + _spec("speed.walking.base", "set", "innate-speed-walking", ["speeds"], + requires_number=True, mode="pass_through"), + _spec("defense.magical-sleep", "immunity", "magical-sleep", + ["defenses"], mode="pass_through"), + _spec("save.advantage.charmed", "advantage", "saving-throws", ["saves"], + mode="pass_through", restrictions_supported=True, + restriction_codes=("condition:charmed:avoid-or-end",)), + _spec("spell.healing.bonus", "bonus", "spell-group-healing", + ["spell_output"], requires_number=True, mode="pass_through"), + _spec("class.subclass.selection", "set", "subclass", (), + mode="structural"), )) HANDLER_BY_PATTERN = {(spec.type_name, spec.subtype): spec for spec in HANDLERS} @@ -120,6 +136,18 @@ def _spec(handler_id, type_name, subtype, affects, **kwargs): ("race", "class", "background", "feat", "condition", "item")) MAX_MECHANICAL_MAGNITUDE = 1_000_000 +_RESTRICTION_CODE_BY_TEXT = { + "Made to avoid or end the Charmed condition": + "condition:charmed:avoid-or-end", +} + + +def normalize_restriction(value): + """Map reviewed upstream prose to a closed, inert semantic code.""" + if not isinstance(value, str) or not value: + return None + return _RESTRICTION_CODE_BY_TEXT.get(value) + # Fixed-code adapter/evaluator gaps. Reasons and blast radii are code-owned; # player/source text never becomes a pattern or reason. @@ -463,6 +491,45 @@ def _selected_choice_evidence(character, bucket, modifier): return None +def _selected_option_evidence(character, bucket, modifier): + """Resolve a modifier whose component is an explicitly selected option. + + Some DDB option-granted effects use the selected option definition ID as + ``componentId`` rather than the parent feature ID. The selected value is + already present in the source-bucket choice row; inactive option catalogs + are not activation evidence. + """ + component_id = modifier.get("componentId") + if type(component_id) is not int: + return None + for choice in (character.get("choices") or {}).get(bucket) or []: + option_id = choice.get("optionValue") + if type(option_id) is not int or option_id != component_id: + continue + return { + "kind": "selected_option", + "option_id": component_id, + "option_selected": True, + } + return None + + +def _race_base_speed_evidence(character, bucket, modifier): + """Corroborate DDB's race speed modifier against the explicit speed row.""" + if (bucket != "race" or modifier.get("type") != "set" + or modifier.get("subType") != "innate-speed-walking"): + return None + walk = (((character.get("race") or {}).get("weightSpeeds") or {}) + .get("normal") or {}).get("walk") + value = modifier.get("value") + if (type(walk) is int and type(value) is int and walk == value): + return { + "kind": "race_base_speed_matches", + "speed_ft": value, + } + return None + + def classify_modifiers(character): """Return ``ledger`` and the only modifiers arithmetic may consume.""" inventory_activation = _inventory_activation(character) @@ -532,19 +599,25 @@ def classify_modifiers(character): component_id = modifier.get("componentId") choice_evidence = _selected_choice_evidence( character, bucket, modifier) - if choice_evidence: - record["activation_evidence"] = choice_evidence - if modifier.get("isGranted") is False and not choice_evidence: + option_evidence = _selected_option_evidence( + character, bucket, modifier) + speed_evidence = _race_base_speed_evidence( + character, bucket, modifier) + source_evidence = choice_evidence or option_evidence or speed_evidence + if source_evidence: + record["activation_evidence"] = source_evidence + if modifier.get("isGranted") is False and not source_evidence: record.update(state="inactive", reason="no selected builder-choice evidence") - elif modifier.get("isGranted") is not True and not choice_evidence: + elif modifier.get("isGranted") is not True and not source_evidence: record.update(state="unsupported", reason="granting evidence is missing or malformed") elif bucket not in SUPPORTED_MODIFIER_BUCKETS: record.update(state="unsupported", reason="modifier source bucket is not supported") elif component_id is not None and item is None \ - and component_id not in known_components: + and component_id not in known_components \ + and not source_evidence: record.update(state="unsupported", reason="granting source component could not be resolved") elif feature_activation.get(component_id) is False: @@ -562,13 +635,19 @@ def classify_modifiers(character): record.update(state="inactive", reason="activation evidence is false") elif not spec: record.update(state="unsupported", reason="no registered handler") - elif record["restriction"] and not spec.restrictions_supported: + elif record["restriction"] and ( + not spec.restrictions_supported + or record["restriction"] not in spec.restriction_codes): record.update(state="unsupported", reason="restriction semantics are not supported") elif spec.requires_number and ( - not isinstance(modifier.get("value"), int) - or isinstance(modifier.get("value"), bool) - or abs(modifier.get("value")) > MAX_MECHANICAL_MAGNITUDE): + (not isinstance(modifier.get("value"), int) + or isinstance(modifier.get("value"), bool) + or abs(modifier.get("value")) > MAX_MECHANICAL_MAGNITUDE) + and not (spec.accepts_stat_id + and modifier.get("value") is None + and type(modifier.get("statId")) is int + and modifier.get("statId") in range(1, 7))): record.update(state="invalid", reason="handler requires a finite integer value") elif spec.handler_id == "hp.per-level" and ( @@ -577,16 +656,19 @@ def classify_modifiers(character): record.update(state="unsupported", reason="granting class level could not be resolved unambiguously") else: - record.update(state="applied" if spec.mode == "apply" else "pass_through", - reason=None) - normalized = dict(modifier) - normalized["_handler_id"] = spec.handler_id - normalized["_source_bucket"] = bucket - if (spec.handler_id == "hp.per-level" - and component_id in feature_class_levels): - normalized["_granting_class_level"] = next(iter( - feature_class_levels[component_id])) - applied.append(normalized) + record.update( + state=("structural" if spec.mode == "structural" else + "applied" if spec.mode == "apply" else "pass_through"), + reason=None) + if spec.mode != "structural": + normalized = dict(modifier) + normalized["_handler_id"] = spec.handler_id + normalized["_source_bucket"] = bucket + if (spec.handler_id == "hp.per-level" + and component_id in feature_class_levels): + normalized["_granting_class_level"] = next(iter( + feature_class_levels[component_id])) + applied.append(normalized) record["finding_id"] = _finding_id(record) ledger.append(record) return {"ledger": ledger, "applied": applied} diff --git a/charactercheck/source.py b/charactercheck/source.py index c652ebc..58b8a0e 100644 --- a/charactercheck/source.py +++ b/charactercheck/source.py @@ -1107,7 +1107,7 @@ def normalized_hash(character): "overrideHitPoints", "removedHitPoints", "temporaryHitPoints", "currentXp", "alignmentId", "inspiration", "conditions", "feats", "spellSlots", "pactMagic", "currencies", "deathSaves", "customItems", - "preferences", "choices", + "preferences", "choices", "options", } _SAFE_PREFERENCE_KEYS = { "enableOptionalClassFeatures", "enableOptionalOrigins", "encumbranceType", @@ -1253,6 +1253,12 @@ def _sanitize_modifier(value, coverage, path): key for key in source_field_registry.MODIFIER_SEMANTIC_KEYS if key in value and value[key] not in (None, False, "", [], {}) } + # DDB commonly duplicates the already-retained integer ``value`` in + # ``fixedValue``. Exact numeric equality contributes no additional + # semantics and must not manufacture a coverage gap. + if (type(value.get("value")) is int + and value.get("fixedValue") == value.get("value")): + omitted_semantics.discard("fixedValue") if omitted_semantics: spec = semantic_registry.handler_for(value) scope = (spec.affects if spec is not None @@ -1273,7 +1279,8 @@ def _sanitize_modifier(value, coverage, path): if isinstance(restriction, str) and restriction: # Its presence changes whether a handler can be applied, while its # player/source-authored prose must not cross the snapshot boundary. - out["restriction"] = "present; source text omitted" + out["restriction"] = (semantic_registry.normalize_restriction( + restriction) or "present; source text omitted") return out @@ -1470,10 +1477,94 @@ def _sanitize_choices(value, coverage): if entries is None: out[safe_bucket] = None continue - out[safe_bucket] = [ - _closed_object(row, _CHOICE_KEYS, coverage, path="choices[]") - for row in entries if isinstance(row, dict) - ] + rows = [] + for row in entries: + if not isinstance(row, dict): + continue + # Labels/order and the inactive option universe do not identify + # the active selection. ``optionValue`` is the activation join. + ignored = {"displayOrder", "label", "optionIds"} + if row.get("optionValue") is not None and row.get("subType"): + ignored.add("defaultSubtypes") + rows.append(_closed_object( + row, _CHOICE_KEYS, coverage, path="choices[]", + ignored=ignored)) + out[safe_bucket] = rows + return out + + +_OPTION_BUCKETS = frozenset({"race", "class", "background", "feat", "item"}) +_OPTION_METADATA_KEYS = frozenset({ + "entityTypeId", "name", "sourceId", "sourcePageNumber", +}) +_OPTION_MECHANICAL_SCOPES = { + "activation": frozenset({"attacks", "resources"}), + "creatureRules": frozenset({"attacks", "defenses", "resources"}), + "spellListIds": frozenset({ + "spellcasting", "spell_save_dc", "spell_attack_bonus", + "spell_output", "prepared_spells", + }), +} + + +def _selected_option_ids(choices): + selected = {} + for bucket, rows in (choices or {}).items(): + if bucket not in _OPTION_BUCKETS or not isinstance(rows, list): + continue + for row in rows: + if isinstance(row, dict) and row.get("optionValue") is not None: + selected.setdefault(bucket, set()).add(str(row["optionValue"])) + return selected + + +def _sanitize_options(value, choices, coverage): + """Retain only explicitly selected option IDs and route their mechanics. + + The builder's inactive option catalog is not character state. Selected + definitions can still carry structured mechanics, so nonempty reviewed + mechanical keys produce narrow coverage rather than being ignored. + """ + if not isinstance(value, dict): + coverage["unclassified_nested_omitted"] = True + return {} + selected = _selected_option_ids(choices) + out = {} + for bucket, rows in value.items(): + if bucket not in _OPTION_BUCKETS: + if rows not in (None, [], {}): + coverage["unclassified_nested_omitted"] = True + continue + if rows is None: + out[bucket] = None + continue + if not isinstance(rows, list): + coverage["unclassified_nested_omitted"] = True + continue + kept = [] + for row in rows: + if not isinstance(row, dict): + coverage["unclassified_nested_omitted"] = True + continue + definition = row.get("definition") or {} + identifier = definition.get("id") if isinstance(definition, dict) else None + if str(identifier) not in selected.get(bucket, set()): + continue + clean = _closed_object( + row, {"componentId", "componentTypeId", "definition"}, + coverage, path=f"options.{bucket}[]") + clean_definition = {"id": identifier} + for key, child in definition.items(): + if key == "id" or key in _OPTION_METADATA_KEYS or _privacy_key(key): + continue + scope = _OPTION_MECHANICAL_SCOPES.get(key) + if scope is None: + coverage["unclassified_nested_omitted"] = True + elif child not in (None, False, "", [], {}): + coverage[SOURCE_COVERAGE_SCOPE_KEY].update(scope) + clean["definition"] = clean_definition + kept.append(clean) + out[bucket] = kept return out @@ -1576,6 +1667,9 @@ def _privacy_filter_with_coverage(character, *, include_persona=False): value["modifiers"], _sanitize_modifier, coverage, "modifiers") if isinstance(value.get("choices"), dict): value["choices"] = _sanitize_choices(value["choices"], coverage) + if "options" in value: + value["options"] = _sanitize_options( + value["options"], value.get("choices"), coverage) filtered_values = [] for record in value.get("characterValues") or []: diff --git a/charactercheck/source_field_registry.py b/charactercheck/source_field_registry.py index 5fd2f02..253e2c7 100644 --- a/charactercheck/source_field_registry.py +++ b/charactercheck/source_field_registry.py @@ -47,12 +47,15 @@ # None means the key remains truly unclassified. An empty set means the key is # reviewed non-mechanical metadata that may be omitted without changing trust. TOP_LEVEL = { - "activeSourceCategories": SPELL, + # Builder filters and score-generation method describe how the UI was + # configured, not additional mechanics beyond the explicit selected + # character records retained by this adapter. + "activeSourceCategories": frozenset(), "adjustmentXp": frozenset({"resources"}), "canEdit": frozenset(), "choices": CLASS_BUILD | RACE_BUILD | BACKGROUND_BUILD | FEAT_BUILD | ITEM, "choiceDefinitions": CLASS_BUILD | RACE_BUILD | BACKGROUND_BUILD | FEAT_BUILD | ITEM, - "configuration": frozenset({"abilities", "inventory"}), + "configuration": frozenset(), "creatures": frozenset({"attacks", "defenses", "resources"}), "customActions": ACTION, "customDefenseAdjustments": frozenset({"ac", "saves", "defenses"}), @@ -220,7 +223,9 @@ "proficiency:calligraphers-supplies": frozenset({"skills"}), "set-base:darkvision": frozenset({"senses"}), "set:innate-speed-walking": frozenset({"speeds"}), - "set:subclass": CLASS_BUILD, + # ``set:subclass`` is a redundant selection marker. The explicit + # ``subclassDefinition`` and its level-gated features are authoritative. + "set:subclass": frozenset(), } diff --git a/docs/human-differential-testing.md b/docs/human-differential-testing.md new file mode 100644 index 0000000..0ac9c30 --- /dev/null +++ b/docs/human-differential-testing.md @@ -0,0 +1,81 @@ +# Human differential testing + +Use this protocol to learn D&D Beyond state semantics without treating one +sheet, one UI label, or one parser output as a general rule. Use a disposable, +publicly shared test character whose owner explicitly agreed to the test. Do +not publish the resulting snapshots: they contain minimized mechanical +character data even though account, persona, notes, images, and prose are +removed by default. + +## A → B → A protocol + +1. Write one falsifiable hypothesis and the single UI mutation that tests it. +2. Capture baseline **A** without changing anything else: + + ```console + python3 -m charactercheck snapshot CHARACTER_REF > a.json + ``` + +3. Make exactly one UI change and record what the human sees, without using + CharacterCheck's prediction as the answer. +4. Capture **B**, then compare it with **A**: + + ```console + python3 -m charactercheck snapshot CHARACTER_REF > b.json + python3 -m charactercheck diff CHARACTER_REF --baseline a.json + ``` + +5. Reverse the UI change, capture **A2**, and prove the mechanical revision + returns to the baseline: + + ```console + python3 -m charactercheck snapshot CHARACTER_REF > a2.json + python3 -m charactercheck diff CHARACTER_REF --baseline a.json + ``` + +6. Record only minimized changed paths, the human-observed result, and the + CharacterCheck finding/trust transition. Delete working snapshots on the + agreed schedule. + +An omitted-source comparison can be `indeterminate` while still naming known +changes. That is evidence about the adapter boundary, not permission to call +the mutation understood. + +## Confidence ladder + +- **Observed:** one A → B change produced a plausible source delta. +- **Reversible:** B → A2 restored the exact mechanical revision. +- **Replicated:** the same mapping held on a second independently built sheet. +- **Production-guarded:** a privacy-minimized synthetic fixture, negative case, + and trust-monotonicity regression ship in CI. + +Only production-guarded mappings may make a family `trusted`. Human statements +remain evidence, not parser defaults. + +## First corpus + +| Sheet | Single-variable trials | Primary issues | +| --- | --- | --- | +| 2024 Fighter 3–5 | selected mastery; mastered/non-mastered weapon; equip flips; shield; two-handed/offhand | COMBAT-001 | +| 2014 prepared caster | prepared/unprepared; legacy markers; class spell source | RULES-001, CAST-001 | +| Paladin/Warlock | standard and Pact slot use/restoration; source-specific spells | SLOT-001, CAST-001 | +| Class + species + feat caster | always-prepared, known, and prepared spells from three sources | CAST-001 | +| Magic-item user | carried/stashed/equipped/attuned/charges/granted spell | MOD-001, AC-001 | +| Homebrew/manual override | AC, ability, speed, attack, and resource overrides one at a time | MOD-001, TEST-001 | + +Do not change species, subclass, or several builder choices on a live campaign +character merely to obtain a diff. Those tests belong on disposable sheets. + +## Human comprehension round + +After parser differentials pass, give `report --brief`, `intake`, and the +value-free table-evaluation projection to a GM or agent without the answer +key. Measure whether they: + +- distinguish trusted-within-coverage from complete or rules-legal; +- ask the supplied confirmation question instead of guessing; +- decline unsupported/unknown values; +- recognize mutable player/session authority; +- recover after a correction without retaining the old state. + +Score parser correctness, agent behavior, and human comprehension separately. diff --git a/llms.txt b/llms.txt index 2e1891c..ac36e8f 100644 --- a/llms.txt +++ b/llms.txt @@ -75,6 +75,12 @@ ECOSYSTEM: complete rules engine. DDB, Foundry, and other outputs are attributed claims or oracles; unexplained disagreement remains non-trusted conflict. +OBSERVED DIRECT FACTS: + Report schema v2 can expose reviewed walking speed, darkvision, magical-sleep + immunity, closed conditional-save evidence, and spell-group healing bonuses. + Treat each canonical field's state as authoritative over the rendered value; + other omitted mechanics can still leave the same family unsupported. + EXIT (command-specific): derive/report: 0 no lint/unhandled, 1 lint only, 2 any unhandled record. diff: 0 complete and unchanged, 1 any named change or indeterminate omitted- diff --git a/tests/test_canonical_trust.py b/tests/test_canonical_trust.py index 54db601..307e977 100644 --- a/tests/test_canonical_trust.py +++ b/tests/test_canonical_trust.py @@ -203,7 +203,8 @@ def test_noncaster_spell_fields_are_not_applicable(self): field_id: field for field_id, field in report["fields"].items() if field_id.startswith("spellcasting.") } - self.assertEqual(len(spell_fields), 6) + self.assertEqual(len(spell_fields), 7) + self.assertIn("spellcasting.output_modifiers", spell_fields) for field_id, field in spell_fields.items(): with self.subTest(field=field_id): self.assertEqual(field["state"], "not_applicable") diff --git a/tests/test_engine.py b/tests/test_engine.py index a97914b..78cf8a8 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -59,6 +59,10 @@ def test_weapon_property_is_not_claimed_as_learned_mastery(self): self.assertEqual(self.r["combat"]["masteries_known"], []) self.assertIn( "Sap", self.r["combat"]["mastery_properties_on_weapons"]) + mace = next(row for row in self.r["combat"]["weapons"] + if row["base_name"] == "Mace") + self.assertIn("Sap", mace["properties"]) + self.assertEqual(mace["mastery"], []) class TestValidatedDDBDifferentials(unittest.TestCase): diff --git a/tests/test_live_schema_evidence.py b/tests/test_live_schema_evidence.py new file mode 100644 index 0000000..c280a0d --- /dev/null +++ b/tests/test_live_schema_evidence.py @@ -0,0 +1,261 @@ +"""Regressions from the second privacy-safe Shalia schema review.""" + +import copy +import json +import os +import unittest + +from charactercheck import engine, registry, source + + +FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "torvald.json") + + +def character(): + with open(FIXTURE, encoding="utf-8") as stream: + value = json.load(stream)["data"] + value["modifiers"] = {bucket: [] for bucket in value["modifiers"]} + value["characterValues"] = [] + for item in value["inventory"]: + item["definition"]["grantedModifiers"] = [] + return value + + +def add(value, modifier, bucket="race"): + value["modifiers"].setdefault(bucket, []).append(modifier) + + +class TestObservedModifierSemantics(unittest.TestCase): + def test_subclass_marker_is_structural_and_never_poisonous(self): + value = character() + add(value, { + "type": "set", "subType": "subclass", "value": None, + "isGranted": True, + }, "class") + + classified = registry.classify_modifiers(value) + self.assertEqual(classified["ledger"][0]["state"], "structural") + self.assertEqual(classified["applied"], []) + report = engine.derive_data(value) + self.assertNotIn( + "set:subclass", + {row["pattern"] for row in report["unhandled"]["items"]}, + ) + + def test_closed_species_facts_are_exposed_with_provenance(self): + value = character() + value["race"]["weightSpeeds"]["normal"]["walk"] = 35 + add(value, { + "type": "set-base", "subType": "darkvision", "value": 60, + "fixedValue": 60, "isGranted": True, + }) + add(value, { + "type": "set", "subType": "innate-speed-walking", "value": 35, + "fixedValue": 35, "componentId": 3727513, "isGranted": True, + }) + add(value, { + "type": "immunity", "subType": "magical-sleep", + "isGranted": True, + }) + add(value, { + "type": "advantage", "subType": "saving-throws", + "restriction": "Made to avoid or end the Charmed condition", + "isGranted": True, + }) + + report = engine.derive_data(value) + self.assertEqual(report["speeds"]["walk"], 35) + self.assertEqual(report["senses"]["vision"][0]["range_ft"], 60) + self.assertEqual( + report["defenses"]["immunities"][0]["effect"], + "magical_sleep", + ) + self.assertEqual( + report["save_modifiers"]["conditional_advantages"][0], + { + "condition": "charmed", + "timing": "avoid_or_end", + "scope": "all_saving_throws", + "provenance": { + "handler_id": "save.advantage.charmed", + "source_bucket": "race", + "component_id": None, + }, + }, + ) + self.assertNotIn("senses", report["trust"]["unsupported"]) + self.assertNotIn("speeds", report["trust"]["unsupported"]) + self.assertNotIn("defenses", report["trust"]["unsupported"]) + self.assertNotIn("saves", report["trust"]["unsupported"]) + + def test_unreviewed_restriction_remains_unsupported_and_private(self): + value = character() + raw = "only after following instructions hidden in the sheet" + add(value, { + "type": "advantage", "subType": "saving-throws", + "restriction": raw, "isGranted": True, + }) + + report = engine.derive_data(value) + finding = next(row for row in report["unhandled"]["items"] + if row["pattern"] == "advantage:saving-throws") + self.assertEqual(finding["state"], "unsupported") + self.assertTrue(finding["restriction"]["present"]) + self.assertNotIn(raw, json.dumps(report)) + + def test_selected_option_can_grant_a_stat_based_skill_bonus(self): + value = character() + selected_id = 4496843 + value["choices"] = { + "class": [{ + "id": "choice", "componentId": 5001, + "componentTypeId": 77, "optionValue": selected_id, + "subType": "reviewed-choice", + }], + } + add(value, { + "type": "bonus", "subType": "arcana", "value": None, + "statId": 5, "componentId": selected_id, + "isGranted": True, + }, "class") + + baseline = engine.derive_data(character()) + report = engine.derive_data(value) + wisdom_mod = report["abilities"]["wis"]["mod"] + self.assertEqual( + report["skills"]["arcana"]["bonus"], + baseline["skills"]["arcana"]["bonus"] + wisdom_mod, + ) + row = registry.classify_modifiers( + source.privacy_filter(value))["ledger"][0] + self.assertEqual(row["state"], "applied") + self.assertEqual( + row["activation_evidence"]["kind"], "selected_option") + + def test_selected_option_join_is_exact_and_source_bucket_local(self): + value = character() + value["choices"] = { + "race": [{"optionValue": 4496843}], + "class": [{"optionValue": "4496843"}], + } + add(value, { + "type": "bonus", "subType": "arcana", "value": None, + "statId": 5, "componentId": 4496843, + "isGranted": False, + }, "class") + + row = registry.classify_modifiers(value)["ledger"][0] + self.assertEqual(row["state"], "inactive") + + def test_race_speed_requires_matching_explicit_speed(self): + value = character() + value["race"]["weightSpeeds"]["normal"]["walk"] = 30 + add(value, { + "type": "set", "subType": "innate-speed-walking", "value": 35, + "componentId": 3727513, "isGranted": False, + }) + + row = registry.classify_modifiers(value)["ledger"][0] + self.assertEqual(row["state"], "inactive") + + def test_selected_option_can_activate_an_ungranted_modifier(self): + value = character() + value["choices"] = {"class": [{"optionValue": 4496843}]} + add(value, { + "type": "bonus", "subType": "religion", "value": None, + "statId": 5, "componentId": 4496843, + "isGranted": False, + }, "class") + + row = registry.classify_modifiers(value)["ledger"][0] + self.assertEqual(row["state"], "applied") + self.assertEqual(row["activation_evidence"]["kind"], "selected_option") + + +class TestSelectedOptionPrivacyBoundary(unittest.TestCase): + def test_builder_configuration_is_reviewed_nonmechanical_input(self): + value = character() + value["activeSourceCategories"] = [1, 2, 3] + value["configuration"] = { + "startingEquipmentType": 2, + "showHelpText": False, + } + + filtered, coverage = source.privacy_filter_with_coverage(value) + self.assertNotIn("activeSourceCategories", filtered) + self.assertNotIn("configuration", filtered) + self.assertFalse(coverage["unclassified_nested_omitted"]) + self.assertEqual(coverage["scoped_mechanical_omissions"], []) + + def test_inactive_catalog_and_builder_labels_do_not_create_global_doubt(self): + value = character() + selected_id = 4496843 + value["choices"] = { + "class": [{ + "id": "choice", "componentId": 5001, + "componentTypeId": 77, "optionValue": selected_id, + "subType": "reviewed-choice", "label": "omitted", + "displayOrder": 1, "optionIds": [selected_id, 999999], + "defaultSubtypes": ["reviewed-choice"], + }], + "choiceDefinitions": [{"options": [{"id": selected_id}]}], + } + value["options"] = { + "class": [ + { + "componentId": 5001, "componentTypeId": 77, + "definition": { + "id": selected_id, "name": "omitted", + "description": "omitted", "sourceId": 1, + "sourcePageNumber": 1, "entityTypeId": 2, + "activation": {}, "creatureRules": [], + "spellListIds": [], + }, + }, + { + "componentId": 5001, "componentTypeId": 77, + "definition": { + "id": 999999, + "futureUnknownMechanic": {"danger": True}, + }, + }, + ], + } + + filtered, coverage = source.privacy_filter_with_coverage(value) + self.assertFalse(coverage["unclassified_nested_omitted"]) + self.assertEqual(coverage["scoped_mechanical_omissions"], []) + self.assertEqual( + [row["definition"]["id"] for row in filtered["options"]["class"]], + [selected_id], + ) + self.assertNotIn("label", filtered["choices"]["class"][0]) + self.assertNotIn("optionIds", filtered["choices"]["class"][0]) + + def test_option_catalog_selection_is_source_bucket_local(self): + value = character() + value["choices"] = {"race": [{"optionValue": 4496843}]} + value["options"] = { + "class": [{"definition": { + "id": 4496843, + "futureUnknownMechanic": {"danger": True}, + }}], + } + + filtered, coverage = source.privacy_filter_with_coverage(value) + self.assertEqual(filtered["options"]["class"], []) + self.assertFalse(coverage["unclassified_nested_omitted"]) + + def test_nonredundant_modifier_semantics_keep_narrow_coverage(self): + value = character() + add(value, { + "type": "set-base", "subType": "darkvision", "value": 60, + "fixedValue": 90, "isGranted": True, + }) + + _, coverage = source.privacy_filter_with_coverage(value) + self.assertEqual(coverage["scoped_mechanical_omissions"], ["senses"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_modifier_registry.py b/tests/test_modifier_registry.py index ce347a2..9556f0c 100644 --- a/tests/test_modifier_registry.py +++ b/tests/test_modifier_registry.py @@ -48,11 +48,11 @@ class TestSourceModifierScopeRegistry(unittest.TestCase): def test_registry_fingerprint_is_reviewed_and_pinned(self): self.assertEqual( source_field_registry.REGISTRY_FINGERPRINT, - "sha256:2eafad340d9a34d7a091d085c057ef9dc85375d524c65f8f6621b59fbe1c88d7", + "sha256:7701df04ec8db8d6b5f21fed7b51e7c88f5250358416a404e73b0a147b5c2d26", ) self.assertEqual( source.SOURCE_SCHEMA_FINGERPRINT, - "sha256:06a73a0cf1a270c2cb7c3a4fa4da1f743db3a2a28d1199afa9e21d993b36c6c1", + "sha256:52d8ecdd0b40f53660b621419aa5da0ffd5b6d4032b97b352a5d358635985b55", ) def test_source_family_catalog_matches_engine_catalog(self): @@ -95,7 +95,7 @@ def test_patterns_and_handler_ids_are_unique(self): self.assertEqual(len(registry.HANDLER_BY_PATTERN), len(patterns)) def test_every_handler_has_complete_machine_routable_metadata(self): - allowed_modes = {"apply", "pass_through"} + allowed_modes = {"apply", "pass_through", "structural"} known_families = set(engine.FAMILY_CATALOG) for spec in (*registry.HANDLERS, registry.LANGUAGE_HANDLER): @@ -103,11 +103,18 @@ def test_every_handler_has_complete_machine_routable_metadata(self): self.assertTrue(spec.handler_id) self.assertTrue(spec.type_name) self.assertTrue(spec.subtype) - self.assertTrue(spec.affects) + if spec.mode == "structural": + self.assertEqual(spec.affects, ()) + else: + self.assertTrue(spec.affects) self.assertLessEqual(set(spec.affects), known_families) self.assertIn(spec.mode, allowed_modes) self.assertIsInstance(spec.requires_number, bool) + self.assertIsInstance(spec.accepts_stat_id, bool) self.assertIsInstance(spec.restrictions_supported, bool) + self.assertIsInstance(spec.restriction_codes, tuple) + if spec.restrictions_supported: + self.assertTrue(spec.restriction_codes) self.assertEqual(spec.rules_profiles, (source.RULES_PROFILE,))