fpf-thinking-map 1.9.2__tar.gz → 1.9.4__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.4}/PKG-INFO +2 -2
  2. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/README.md +1 -1
  3. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/fpf_thinking_map/__init__.py +8 -0
  4. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/fpf_thinking_map/primitives.py +29 -0
  5. fpf_thinking_map-1.9.4/fpf_thinking_map/reachability.py +109 -0
  6. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/fpf_thinking_map/traversal.py +27 -0
  7. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/fpf_thinking_map/verify.py +152 -0
  8. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4/fpf_thinking_map.egg-info}/PKG-INFO +2 -2
  9. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/fpf_thinking_map.egg-info/SOURCES.txt +1 -0
  10. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/pyproject.toml +1 -1
  11. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/LICENSE +0 -0
  12. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/NOTICE +0 -0
  13. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/fpf_thinking_map/authorization.py +0 -0
  14. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/fpf_thinking_map/examples.py +0 -0
  15. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/fpf_thinking_map/guards.py +0 -0
  16. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/fpf_thinking_map/logic.py +0 -0
  17. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/fpf_thinking_map/move_intent.py +0 -0
  18. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/fpf_thinking_map/pending_input.py +0 -0
  19. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/fpf_thinking_map/py.typed +0 -0
  20. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/fpf_thinking_map/state.py +0 -0
  21. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/fpf_thinking_map.egg-info/dependency_links.txt +0 -0
  22. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/fpf_thinking_map.egg-info/top_level.txt +0 -0
  23. {fpf_thinking_map-1.9.2 → fpf_thinking_map-1.9.4}/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.4
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>
@@ -49,7 +49,7 @@ context. The model can inspect the map and choose; the runtime decides whether
49
49
  the move is valid.
50
50
 
51
51
  [![PyPI](https://img.shields.io/pypi/v/fpf-thinking-map?style=flat-square&label=PyPI&color=3775A9)](https://pypi.org/project/fpf-thinking-map/)
52
- [![Downloads (honest)](https://img.shields.io/badge/downloads%20%28honest%29-3.8k-1f6feb?style=flat-square)](https://pypistats.org/packages/fpf-thinking-map)
52
+ [![Downloads (honest)](https://img.shields.io/badge/downloads%20%28honest%29-4k-1f6feb?style=flat-square)](https://pypistats.org/packages/fpf-thinking-map)
53
53
  [![Python](https://img.shields.io/pypi/pyversions/fpf-thinking-map?style=flat-square&label=Python&color=f0b429)](https://github.com/igareosh/fpf-agentic-thinking-map/blob/main/pyproject.toml)
54
54
  [![License](https://img.shields.io/pypi/l/fpf-thinking-map?style=flat-square&label=license&color=57c7bd)](https://github.com/igareosh/fpf-agentic-thinking-map/blob/main/LICENSE)
55
55
  [![Zero dependencies](https://img.shields.io/badge/dependencies-0-ff9f43?style=flat-square)](https://github.com/igareosh/fpf-agentic-thinking-map/blob/main/pyproject.toml)
@@ -13,7 +13,7 @@ context. The model can inspect the map and choose; the runtime decides whether
13
13
  the move is valid.
14
14
 
15
15
  [![PyPI](https://img.shields.io/pypi/v/fpf-thinking-map?style=flat-square&label=PyPI&color=3775A9)](https://pypi.org/project/fpf-thinking-map/)
16
- [![Downloads (honest)](https://img.shields.io/badge/downloads%20%28honest%29-3.8k-1f6feb?style=flat-square)](https://pypistats.org/packages/fpf-thinking-map)
16
+ [![Downloads (honest)](https://img.shields.io/badge/downloads%20%28honest%29-4k-1f6feb?style=flat-square)](https://pypistats.org/packages/fpf-thinking-map)
17
17
  [![Python](https://img.shields.io/pypi/pyversions/fpf-thinking-map?style=flat-square&label=Python&color=f0b429)](https://github.com/igareosh/fpf-agentic-thinking-map/blob/main/pyproject.toml)
18
18
  [![License](https://img.shields.io/pypi/l/fpf-thinking-map?style=flat-square&label=license&color=57c7bd)](https://github.com/igareosh/fpf-agentic-thinking-map/blob/main/LICENSE)
19
19
  [![Zero dependencies](https://img.shields.io/badge/dependencies-0-ff9f43?style=flat-square)](https://github.com/igareosh/fpf-agentic-thinking-map/blob/main/pyproject.toml)
@@ -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
+ )
@@ -438,6 +438,35 @@ class TransitionPrimitive:
438
438
  does — the engine only makes the
439
439
  option visible, it never picks one. Whether an alternative actually
440
440
  satisfies the goal is a domain judgment outside this library's scope.
441
+
442
+ guard_expression: names a DecisionRule (fpf_thinking_map.logic) that
443
+ must currently recommend *this* transition_id for the fire to
444
+ proceed unopposed — the routing-policy counterpart to
445
+ required_gate_id/required_evidence. Checked in
446
+ ThinkingMapTraversal.attempt_transition(), not by the guard engine
447
+ despite the name (guards.py stays pure ActiveState predicates; a
448
+ LogicLayer reference only ThinkingMapTraversal can resolve doesn't
449
+ belong there).
450
+
451
+ Deliberately not an ESCALATE: a routing-policy mismatch is not
452
+ "needs a human," it's "the map disagrees with the proposed move and
453
+ knows what it would prefer instead" — the same shape as
454
+ COLLECT_EVIDENCE, not requires_human_authorization. Fires
455
+ REVISE_PLAN (declared in OutcomeKind since the traversal engine's
456
+ outcome space was first drawn up, never previously returned by any
457
+ code path) with the rule's actual current recommendation in
458
+ Outcome.alternatives, so an agentic caller can retry the recommended
459
+ transition_id in the same turn — solution-seeking stays inside the
460
+ map's own vocabulary, no external tool, no waiting on a person.
461
+
462
+ Silent no-op, matching required_gate_id's existing convention for a
463
+ dangling reference, when: no logic_layer is bound on the traversal,
464
+ the named rule doesn't exist in it, or the rule currently has no
465
+ active recommendation at all (condition false with no
466
+ action_if_false, or a HINT/WARN rule's vacuous-implication
467
+ suppression) — "the policy has no opinion right now" defaults to
468
+ allow, same as not setting guard_expression at all. Only an active
469
+ recommendation for a *different* transition_id blocks.
441
470
  """
442
471
  transition_id: str
443
472
  label: str
@@ -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]
@@ -451,6 +451,33 @@ class ThinkingMapTraversal:
451
451
  missing_evidence=gate.missing_evidence(state.available_evidence_ids),
452
452
  )
453
453
 
454
+ if t.guard_expression and self.logic_layer:
455
+ rule = next(
456
+ (r for r in self.logic_layer.rules if r.name == t.guard_expression),
457
+ None,
458
+ )
459
+ # dangling rule name: same silent no-op as an unresolved
460
+ # required_gate_id above — a missing reference doesn't block.
461
+ if rule:
462
+ _, recommended = rule.evaluate(state)
463
+ # no active recommendation right now ("" from a false
464
+ # condition with no action_if_false, or vacuous HINT/WARN
465
+ # suppression) means the policy has no opinion — allow,
466
+ # same as guard_expression being unset. Only a *different*
467
+ # active recommendation blocks.
468
+ if recommended and recommended != transition_id:
469
+ reason = (
470
+ f"Transition '{transition_id}' does not match routing policy "
471
+ f"'{rule.name}' — policy currently recommends '{recommended}'"
472
+ )
473
+ if rule.explanation:
474
+ reason += f": {rule.explanation}"
475
+ return Outcome(
476
+ kind=OutcomeKind.REVISE_PLAN,
477
+ reason=reason,
478
+ alternatives=[recommended],
479
+ )
480
+
454
481
  allowed, guard_results = self.guard_engine.is_action_allowed(state, transition_id)
455
482
  if not allowed:
456
483
  denials = [r.reason for r in guard_results if r.verdict == GuardVerdict.DENY]
@@ -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,154 @@ 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
+
1741
+ def check_route_gated_transition():
1742
+ """guard_expression -> DecisionRule: routing-policy legality, agentic
1743
+
1744
+ not human-facing. A transition naming a DecisionRule via
1745
+ guard_expression only fires when that rule's current recommendation
1746
+ matches the attempted transition_id — mismatches return REVISE_PLAN
1747
+ (never ESCALATE: no human is asked, nothing is added to
1748
+ pending_authorizations) with the rule's actual recommendation in
1749
+ alternatives, so an agentic caller retries in the same turn, inside
1750
+ the map's own vocabulary."""
1751
+ sm = SemanticMap()
1752
+ sm.register_context(ContextPrimitive("cis", "Compound Intelligence Stack"))
1753
+ sm.register_transition(TransitionPrimitive(
1754
+ "route_local", "route to local/open tier", "cis",
1755
+ "control_plane", "local_open", guard_expression="escalation_policy",
1756
+ ))
1757
+ sm.register_transition(TransitionPrimitive(
1758
+ "route_frontier", "escalate to frontier oracle", "cis",
1759
+ "control_plane", "frontier", guard_expression="escalation_policy",
1760
+ ))
1761
+
1762
+ logic = LogicLayer()
1763
+ logic.add_rule(DecisionRule(
1764
+ name="escalation_policy", condition=RiskAbove("high"),
1765
+ action_if_true="route_frontier", action_if_false="route_local",
1766
+ explanation="routine load stays local; frontier is the rare escalation",
1767
+ kind=RuleKind.ROUTE,
1768
+ ))
1769
+ engine = ThinkingMapTraversal(sm, logic_layer=logic)
1770
+
1771
+ # normal risk: policy recommends route_local. Attempting the
1772
+ # mismatched transition does not fire it, and — the whole point —
1773
+ # does not touch pending_authorizations the way an ESCALATE would.
1774
+ s_normal = engine.build_active_state(
1775
+ RuntimeBinding(active_context_id="cis", risk_level="normal"),
1776
+ current_state="control_plane",
1777
+ )
1778
+ o_mismatch = engine.attempt_transition(s_normal, "route_frontier")
1779
+ assert o_mismatch.kind == OutcomeKind.REVISE_PLAN, o_mismatch.kind
1780
+ assert o_mismatch.kind != OutcomeKind.ESCALATE, (
1781
+ "a routing-policy mismatch must never read as 'needs a human'"
1782
+ )
1783
+ assert o_mismatch.alternatives == ["route_local"], o_mismatch.alternatives
1784
+ assert "escalation_policy" in o_mismatch.reason
1785
+ assert s_normal.current_state == "control_plane", "mismatch must not move state"
1786
+ assert s_normal.pending_authorizations == set(), (
1787
+ "REVISE_PLAN is not an authorization ask — nothing pending for a human"
1788
+ )
1789
+
1790
+ # the matching transition at the same state fires normally
1791
+ o_match = engine.attempt_transition(s_normal, "route_local")
1792
+ assert o_match.kind == OutcomeKind.CONTINUE, o_match.kind
1793
+ assert s_normal.current_state == "local_open"
1794
+
1795
+ # high risk: policy now recommends route_frontier instead. Attempting
1796
+ # the now-wrong default gets REVISE_PLAN naming the correct one —
1797
+ # and the caller can retry it immediately, autonomously, same turn.
1798
+ s_high = engine.build_active_state(
1799
+ RuntimeBinding(active_context_id="cis", risk_level="high"),
1800
+ current_state="control_plane",
1801
+ )
1802
+ o_wrong_tier = engine.attempt_transition(s_high, "route_local")
1803
+ assert o_wrong_tier.kind == OutcomeKind.REVISE_PLAN, o_wrong_tier.kind
1804
+ assert o_wrong_tier.alternatives == ["route_frontier"], o_wrong_tier.alternatives
1805
+ assert s_high.current_state == "control_plane"
1806
+
1807
+ o_retry = engine.attempt_transition(s_high, o_wrong_tier.alternatives[0])
1808
+ assert o_retry.kind == OutcomeKind.CONTINUE, (
1809
+ "the model's own retry, using exactly what REVISE_PLAN handed back, "
1810
+ f"must succeed — got {o_retry.kind}"
1811
+ )
1812
+ assert s_high.current_state == "frontier"
1813
+
1814
+ # dangling reference / no logic_layer bound: same silent no-op as an
1815
+ # unresolved required_gate_id — backward compatible with maps that
1816
+ # never opted into routing policy at all
1817
+ sm2 = SemanticMap()
1818
+ sm2.register_context(ContextPrimitive("cis2", "Test"))
1819
+ sm2.register_transition(TransitionPrimitive(
1820
+ "t_a", "A", "cis2", "start", "mid", guard_expression="no_such_rule",
1821
+ ))
1822
+ engine_no_logic = ThinkingMapTraversal(sm2) # no logic_layer at all
1823
+ s2 = engine_no_logic.build_active_state(
1824
+ RuntimeBinding(active_context_id="cis2"), current_state="start",
1825
+ )
1826
+ o2 = engine_no_logic.attempt_transition(s2, "t_a")
1827
+ assert o2.kind == OutcomeKind.CONTINUE, (
1828
+ f"a dangling guard_expression with no bound logic_layer must not "
1829
+ f"block — got {o2.kind}"
1830
+ )
1831
+
1832
+
1683
1833
  def main():
1684
1834
  print("FPF Thinking Map — Self-verification (horizontal)")
1685
1835
  print("=" * 55)
@@ -1711,6 +1861,8 @@ def main():
1711
1861
  ("authorization receipt (scoped, non-replayable, TOCTOU-safe)", check_authorization_receipt),
1712
1862
  ("pending input / AWAIT (distinct from IDLE)", check_pending_input_await),
1713
1863
  ("move intent / inspect_move (concrete move identity)", check_move_intent),
1864
+ ("reachability (discrete graph analysis, val.pdf ch.10)", check_reachability),
1865
+ ("route-gated transition (guard_expression -> DecisionRule, REVISE_PLAN not ESCALATE)", check_route_gated_transition),
1714
1866
  ]
1715
1867
 
1716
1868
  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.4
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>
@@ -49,7 +49,7 @@ context. The model can inspect the map and choose; the runtime decides whether
49
49
  the move is valid.
50
50
 
51
51
  [![PyPI](https://img.shields.io/pypi/v/fpf-thinking-map?style=flat-square&label=PyPI&color=3775A9)](https://pypi.org/project/fpf-thinking-map/)
52
- [![Downloads (honest)](https://img.shields.io/badge/downloads%20%28honest%29-3.8k-1f6feb?style=flat-square)](https://pypistats.org/packages/fpf-thinking-map)
52
+ [![Downloads (honest)](https://img.shields.io/badge/downloads%20%28honest%29-4k-1f6feb?style=flat-square)](https://pypistats.org/packages/fpf-thinking-map)
53
53
  [![Python](https://img.shields.io/pypi/pyversions/fpf-thinking-map?style=flat-square&label=Python&color=f0b429)](https://github.com/igareosh/fpf-agentic-thinking-map/blob/main/pyproject.toml)
54
54
  [![License](https://img.shields.io/pypi/l/fpf-thinking-map?style=flat-square&label=license&color=57c7bd)](https://github.com/igareosh/fpf-agentic-thinking-map/blob/main/LICENSE)
55
55
  [![Zero dependencies](https://img.shields.io/badge/dependencies-0-ff9f43?style=flat-square)](https://github.com/igareosh/fpf-agentic-thinking-map/blob/main/pyproject.toml)
@@ -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.4"
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"