fpf-thinking-map 1.9.2__tar.gz → 1.9.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {fpf_thinking_map-1.9.2/fpf_thinking_map.egg-info → fpf_thinking_map-1.9.3}/PKG-INFO +1 -1
  2. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/fpf_thinking_map/__init__.py +8 -0
  3. fpf_thinking_map-1.9.3/fpf_thinking_map/reachability.py +109 -0
  4. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/fpf_thinking_map/verify.py +59 -0
  5. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3/fpf_thinking_map.egg-info}/PKG-INFO +1 -1
  6. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/fpf_thinking_map.egg-info/SOURCES.txt +1 -0
  7. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/pyproject.toml +1 -1
  8. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/LICENSE +0 -0
  9. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/NOTICE +0 -0
  10. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/README.md +0 -0
  11. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/fpf_thinking_map/authorization.py +0 -0
  12. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/fpf_thinking_map/examples.py +0 -0
  13. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/fpf_thinking_map/guards.py +0 -0
  14. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/fpf_thinking_map/logic.py +0 -0
  15. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/fpf_thinking_map/move_intent.py +0 -0
  16. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/fpf_thinking_map/pending_input.py +0 -0
  17. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/fpf_thinking_map/primitives.py +0 -0
  18. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/fpf_thinking_map/py.typed +0 -0
  19. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/fpf_thinking_map/state.py +0 -0
  20. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/fpf_thinking_map/traversal.py +0 -0
  21. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/fpf_thinking_map.egg-info/dependency_links.txt +0 -0
  22. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/fpf_thinking_map.egg-info/top_level.txt +0 -0
  23. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fpf-thinking-map
3
- Version: 1.9.2
3
+ Version: 1.9.3
4
4
  Summary: Deterministic Python runtime for lawful agent traversal with explicit state, evidence, authorization, waiting, and move identity.
5
5
  Author-email: "igareosh.com (@igareosh)" <igareosh@igareosh.com>
6
6
  Maintainer-email: "igareosh.com (@igareosh)" <igareosh@igareosh.com>
@@ -15,6 +15,7 @@ Modules:
15
15
  pending_input — declared external dependencies distinguishing AWAIT from IDLE
16
16
  move_intent — a concrete proposed move, distinct from its transition type
17
17
  examples — deploy decision scenarios demonstrating the full system
18
+ reachability — discrete reachability analysis over a map's transition graph
18
19
  verify — self-verification harness
19
20
  """
20
21
 
@@ -95,3 +96,10 @@ from fpf_thinking_map.pending_input import PendingInput, PendingInputStatus
95
96
 
96
97
  # --- Move intent: concrete proposed move, distinct from transition type ---
97
98
  from fpf_thinking_map.move_intent import MoveIntent
99
+
100
+ # --- Reachability: discrete reachability analysis over the transition graph ---
101
+ from fpf_thinking_map.reachability import (
102
+ forward_reachable,
103
+ graph_roots,
104
+ unreachable_transitions,
105
+ )
@@ -0,0 +1,109 @@
1
+ """Discrete reachability analysis over a SemanticMap's declared transition graph.
2
+
3
+ A SemanticMap's transitions form exactly the object M. J. Kochenderfer, S. M.
4
+ Katz, A. L. Corso, and R. J. Moss describe in *Algorithms for Validation*
5
+ (MIT Press, 2026), ch. 10, "Reachability for Discrete Systems": a directed
6
+ graph, one node per state, one edge per transition. Their algorithm 10.1
7
+ (build the graph) and 10.3 (backward/forward reachable sets) are cheap at the
8
+ scale a hand-authored domain map actually runs at -- tens of transitions, not
9
+ thousands -- so there is no excuse for skipping them in favor of hand-picked
10
+ test cases that only exercise the paths someone happened to think of.
11
+
12
+ What this module answers: given the transitions a map declares, is every
13
+ from_state either a named entry point or produced by some other transition's
14
+ to_state? A from_state that is neither is either (a) the map's intentional
15
+ starting point for a bounded context -- the caller sets current_state there
16
+ directly, e.g. because real-world work upstream of this map already happened
17
+ -- or (b) an orphaned typo nobody meant to leave unreachable. This module
18
+ cannot tell those apart on its own, and does not try to: entry_states is a
19
+ required argument, not inferred, because a map's real entry points are a
20
+ domain fact, not something derivable from the graph.
21
+
22
+ Origin story (see check_reachability in verify.py for the worked example):
23
+ a downstream domain map gave a requires_human_authorization transition its
24
+ own dedicated from_state, distinct from its non-destructive twin's. Read cold,
25
+ that dedicated from_state looked unreachable -- nothing in the map produced
26
+ it -- and the obvious "fix" (merge it onto the twin's shared from_state) broke
27
+ four behavior tests, because ThinkingMapTraversal.step() called without an
28
+ explicit transition_id aggregates missing_evidence across every transition
29
+ sharing a from_state, not just the one the caller meant. The state genuinely
30
+ was meant to be entered from outside the graph; the actual defect was that
31
+ nothing recorded that on purpose. entry_states exists so that fact is written
32
+ down once, in code, instead of being tribal knowledge someone has to
33
+ rediscover by breaking tests.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from collections.abc import Iterable
39
+
40
+ from fpf_thinking_map.primitives import TransitionPrimitive
41
+ from fpf_thinking_map.state import SemanticMap
42
+
43
+ __all__ = ["graph_roots", "forward_reachable", "unreachable_transitions"]
44
+
45
+
46
+ def graph_roots(transitions: Iterable[TransitionPrimitive]) -> set[str]:
47
+ """States that no declared transition's to_state ever produces.
48
+
49
+ Every state in the returned set is either an intentional entry point
50
+ or an orphaned from_state -- this function makes no claim about which.
51
+ Useful for auditing a map you didn't author: it's the candidate list
52
+ to sort into "yes, callers arrive here directly" and "nobody meant
53
+ this," by hand, once, rather than rediscovering it via a break like
54
+ the one described in this module's docstring.
55
+ """
56
+ transitions = list(transitions)
57
+ produced = {t.to_state for t in transitions}
58
+ consumed = {t.from_state for t in transitions}
59
+ return consumed - produced
60
+
61
+
62
+ def forward_reachable(
63
+ transitions: Iterable[TransitionPrimitive],
64
+ entry_states: Iterable[str],
65
+ ) -> set[str]:
66
+ """States reachable from entry_states by following declared to_state edges.
67
+
68
+ Plain BFS over the (from_state -> to_state) graph -- algorithm 10.1's
69
+ graph formulation, forward direction. At domain-map scale (tens of
70
+ edges) this is microseconds; there is no reason to approximate it.
71
+ """
72
+ transitions = list(transitions)
73
+ edges: dict[str, list[str]] = {}
74
+ for t in transitions:
75
+ edges.setdefault(t.from_state, []).append(t.to_state)
76
+
77
+ seen = set(entry_states)
78
+ frontier = list(seen)
79
+ while frontier:
80
+ s = frontier.pop()
81
+ for nxt in edges.get(s, []):
82
+ if nxt not in seen:
83
+ seen.add(nxt)
84
+ frontier.append(nxt)
85
+ return seen
86
+
87
+
88
+ def unreachable_transitions(
89
+ sm: SemanticMap,
90
+ entry_states: Iterable[str],
91
+ ) -> list[TransitionPrimitive]:
92
+ """Transitions whose from_state is unreachable given the declared entry points.
93
+
94
+ entry_states must be supplied explicitly. Omitting a state that a
95
+ caller genuinely does set current_state to directly is not a bug in
96
+ this function -- it is the intended failure mode, surfacing every
97
+ undeclared entry point by name instead of silently accepting it. See
98
+ check_reachability in verify.py for both directions demonstrated: the
99
+ library's own build_destructive_action_map() example map is clean when
100
+ "reviewed" is declared as an entry point, and loudly not clean when it
101
+ isn't.
102
+
103
+ Returns transitions in registration order (dict-ordered, matching
104
+ SemanticMap.transitions), not sorted -- callers building error messages
105
+ generally want map-declaration order, not alphabetical.
106
+ """
107
+ transitions = list(sm.transitions.values())
108
+ reachable = forward_reachable(transitions, entry_states)
109
+ return [t for t in transitions if t.from_state not in reachable]
@@ -15,12 +15,14 @@ import traceback
15
15
  from fpf_thinking_map.examples import (
16
16
  build_deploy_decision_map,
17
17
  build_deploy_rules,
18
+ build_destructive_action_map,
18
19
  run_logic_scenario,
19
20
  run_scenario_full_traversal,
20
21
  run_scenario_missing_evidence,
21
22
  run_scenario_role_conflict,
22
23
  run_truth_table_demo,
23
24
  )
25
+ from fpf_thinking_map.reachability import unreachable_transitions
24
26
  from fpf_thinking_map.authorization import (
25
27
  AuthorizationReceipt,
26
28
  compute_state_fingerprint,
@@ -1680,6 +1682,62 @@ def check_move_intent():
1680
1682
  )
1681
1683
 
1682
1684
 
1685
+ def check_reachability():
1686
+ """Discrete reachability over a map's declared transition graph (val.pdf ch.10).
1687
+
1688
+ Worked example, both directions, using the library's own canonical
1689
+ destructive-move map (build_destructive_action_map): delete_records and
1690
+ archive_records both declare from_state="reviewed", which is nobody's
1691
+ to_state in this map — callers arrive at "reviewed" from outside (a
1692
+ dry-run happened upstream), the same shape as the case that motivated
1693
+ this module (see reachability.py's docstring for the full story: an
1694
+ unreachable-looking from_state turned out to be an intentional external
1695
+ entry point, and the "obvious" fix of merging it onto a shared from_state
1696
+ broke four behavior tests by changing what missing_evidence aggregates
1697
+ across).
1698
+
1699
+ entry_states is required, not inferred, precisely so that mistake isn't
1700
+ repeatable: declaring "reviewed" as an entry point is a one-line,
1701
+ reviewable statement of intent instead of something a reachability walk
1702
+ has to guess at.
1703
+ """
1704
+ sm = build_destructive_action_map()
1705
+
1706
+ # declared correctly: "reviewed" is a named entry point, nothing is
1707
+ # unreachable
1708
+ dead = unreachable_transitions(sm, entry_states={"reviewed"})
1709
+ assert not dead, f"unexpected unreachable transitions: {[t.transition_id for t in dead]}"
1710
+
1711
+ # omit the entry point on purpose: the check's whole job is to make an
1712
+ # undeclared entry point loud, not to silently let it through. All three
1713
+ # transitions rooted at "reviewed" must come back.
1714
+ undeclared = unreachable_transitions(sm, entry_states=set())
1715
+ undeclared_ids = {t.transition_id for t in undeclared}
1716
+ assert undeclared_ids == {"delete_records", "archive_records", "log_status"}, undeclared_ids
1717
+
1718
+ # a genuinely orphaned transition — from_state nobody produces and
1719
+ # nobody declared as an entry point — must still be caught even when a
1720
+ # real entry point is declared correctly
1721
+ sm2 = build_destructive_action_map()
1722
+ sm2.register_transition(TransitionPrimitive(
1723
+ transition_id="orphan_move",
1724
+ label="Unreachable by construction — nothing produces 'nowhere'",
1725
+ context_id="data_ops",
1726
+ from_state="nowhere",
1727
+ to_state="deleted",
1728
+ ))
1729
+ orphaned = unreachable_transitions(sm2, entry_states={"reviewed"})
1730
+ assert [t.transition_id for t in orphaned] == ["orphan_move"], (
1731
+ [t.transition_id for t in orphaned]
1732
+ )
1733
+ assert orphaned[0].requires_human_authorization is False, (
1734
+ "orphan_move isn't gated — reachability catches dead transitions "
1735
+ "regardless of whether they happen to be destructive; it is not a "
1736
+ "substitute for requires_human_authorization, it is a separate check "
1737
+ "on a separate property (can the graph get here at all)"
1738
+ )
1739
+
1740
+
1683
1741
  def main():
1684
1742
  print("FPF Thinking Map — Self-verification (horizontal)")
1685
1743
  print("=" * 55)
@@ -1711,6 +1769,7 @@ def main():
1711
1769
  ("authorization receipt (scoped, non-replayable, TOCTOU-safe)", check_authorization_receipt),
1712
1770
  ("pending input / AWAIT (distinct from IDLE)", check_pending_input_await),
1713
1771
  ("move intent / inspect_move (concrete move identity)", check_move_intent),
1772
+ ("reachability (discrete graph analysis, val.pdf ch.10)", check_reachability),
1714
1773
  ]
1715
1774
 
1716
1775
  passed = sum(check(name, fn) for name, fn in checks)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fpf-thinking-map
3
- Version: 1.9.2
3
+ Version: 1.9.3
4
4
  Summary: Deterministic Python runtime for lawful agent traversal with explicit state, evidence, authorization, waiting, and move identity.
5
5
  Author-email: "igareosh.com (@igareosh)" <igareosh@igareosh.com>
6
6
  Maintainer-email: "igareosh.com (@igareosh)" <igareosh@igareosh.com>
@@ -11,6 +11,7 @@ fpf_thinking_map/move_intent.py
11
11
  fpf_thinking_map/pending_input.py
12
12
  fpf_thinking_map/primitives.py
13
13
  fpf_thinking_map/py.typed
14
+ fpf_thinking_map/reachability.py
14
15
  fpf_thinking_map/state.py
15
16
  fpf_thinking_map/traversal.py
16
17
  fpf_thinking_map/verify.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fpf-thinking-map"
3
- version = "1.9.2"
3
+ version = "1.9.3"
4
4
  description = "Deterministic Python runtime for lawful agent traversal with explicit state, evidence, authorization, waiting, and move identity."
5
5
  readme = "README.md"
6
6
  license = "MIT"