fuaran-ui 0.7.0__py3-none-any.whl

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 (94) hide show
  1. fuaran_ui/__init__.py +113 -0
  2. fuaran_ui/ai_tools/__init__.py +86 -0
  3. fuaran_ui/ai_tools/dispatch.py +99 -0
  4. fuaran_ui/ai_tools/introspect.py +190 -0
  5. fuaran_ui/ai_tools/surface.py +194 -0
  6. fuaran_ui/canonical.py +216 -0
  7. fuaran_ui/charts/__init__.py +3391 -0
  8. fuaran_ui/cli/__init__.py +31 -0
  9. fuaran_ui/cli/core.py +387 -0
  10. fuaran_ui/client/__init__.py +77 -0
  11. fuaran_ui/client/client.py +284 -0
  12. fuaran_ui/client/contract.py +199 -0
  13. fuaran_ui/client/session.py +66 -0
  14. fuaran_ui/client/wire.py +250 -0
  15. fuaran_ui/compute/__init__.py +42 -0
  16. fuaran_ui/compute/evaluate.py +658 -0
  17. fuaran_ui/conformance/__init__.py +7 -0
  18. fuaran_ui/conformance/bridge.py +84 -0
  19. fuaran_ui/conformance/decoder_fuzz.py +1046 -0
  20. fuaran_ui/conformance/fuzz_exchange.py +209 -0
  21. fuaran_ui/conformance/harness.py +111 -0
  22. fuaran_ui/conformance/host_capability.py +366 -0
  23. fuaran_ui/conformance/refusal_report.py +123 -0
  24. fuaran_ui/dag.py +277 -0
  25. fuaran_ui/dataframe/__init__.py +149 -0
  26. fuaran_ui/dataframe/codec.py +1104 -0
  27. fuaran_ui/dataframe/evaluate.py +940 -0
  28. fuaran_ui/dataframe/model.py +428 -0
  29. fuaran_ui/elicitation.py +559 -0
  30. fuaran_ui/envelope.py +166 -0
  31. fuaran_ui/function.py +294 -0
  32. fuaran_ui/layout_observer/__init__.py +70 -0
  33. fuaran_ui/layout_observer/flags.py +260 -0
  34. fuaran_ui/layout_observer/observer.py +248 -0
  35. fuaran_ui/limits.py +136 -0
  36. fuaran_ui/merge.py +676 -0
  37. fuaran_ui/model.py +89 -0
  38. fuaran_ui/op_stream/__init__.py +140 -0
  39. fuaran_ui/op_stream/hash_chain.py +230 -0
  40. fuaran_ui/op_stream/in_memory_sink.py +144 -0
  41. fuaran_ui/op_stream/replay.py +375 -0
  42. fuaran_ui/op_stream/types.py +280 -0
  43. fuaran_ui/ops/__init__.py +72 -0
  44. fuaran_ui/ops/apply.py +1053 -0
  45. fuaran_ui/ops/decode.py +236 -0
  46. fuaran_ui/ops/diff.py +305 -0
  47. fuaran_ui/ops/encode.py +11 -0
  48. fuaran_ui/ops/placement.py +504 -0
  49. fuaran_ui/render_fidelity.py +561 -0
  50. fuaran_ui/renderer/__init__.py +152 -0
  51. fuaran_ui/renderer/bindings.py +873 -0
  52. fuaran_ui/renderer/content/fuaran-reference.css +4072 -0
  53. fuaran_ui/renderer/document.py +925 -0
  54. fuaran_ui/renderer/egress.py +596 -0
  55. fuaran_ui/renderer/email.py +1437 -0
  56. fuaran_ui/renderer/html.py +67 -0
  57. fuaran_ui/renderer/markdown.py +1211 -0
  58. fuaran_ui/renderer/notebook.py +406 -0
  59. fuaran_ui/renderer/projection.py +176 -0
  60. fuaran_ui/renderer/render.py +2793 -0
  61. fuaran_ui/renderer/sanitize.py +591 -0
  62. fuaran_ui/renderer/seeds.py +159 -0
  63. fuaran_ui/renderer/theme.py +263 -0
  64. fuaran_ui/result.py +71 -0
  65. fuaran_ui/runtime/__init__.py +22 -0
  66. fuaran_ui/runtime/runtime.py +259 -0
  67. fuaran_ui/runtime/sample.py +57 -0
  68. fuaran_ui/schema/__init__.py +15 -0
  69. fuaran_ui/schema/decode.py +4406 -0
  70. fuaran_ui/schema/encode.py +11 -0
  71. fuaran_ui/schema/types.py +4007 -0
  72. fuaran_ui/shapeguard.py +315 -0
  73. fuaran_ui/style_observer/__init__.py +129 -0
  74. fuaran_ui/style_observer/color.py +91 -0
  75. fuaran_ui/style_observer/flags.py +326 -0
  76. fuaran_ui/style_observer/manifest_flags.py +108 -0
  77. fuaran_ui/style_observer/observer.py +334 -0
  78. fuaran_ui/teleport.py +263 -0
  79. fuaran_ui/theme_manifest/__init__.py +91 -0
  80. fuaran_ui/theme_manifest/decode.py +174 -0
  81. fuaran_ui/theme_manifest/manifest.py +184 -0
  82. fuaran_ui/theme_manifest/project.py +173 -0
  83. fuaran_ui/ui/__init__.py +1663 -0
  84. fuaran_ui/ui/capability.py +242 -0
  85. fuaran_ui/ui/compute.py +759 -0
  86. fuaran_ui/ui/controls.py +313 -0
  87. fuaran_ui/ui/quick.py +381 -0
  88. fuaran_ui/validator/__init__.py +7 -0
  89. fuaran_ui/validator/validate.py +780 -0
  90. fuaran_ui-0.7.0.dist-info/METADATA +1335 -0
  91. fuaran_ui-0.7.0.dist-info/RECORD +94 -0
  92. fuaran_ui-0.7.0.dist-info/WHEEL +4 -0
  93. fuaran_ui-0.7.0.dist-info/entry_points.txt +2 -0
  94. fuaran_ui-0.7.0.dist-info/licenses/LICENSE +215 -0
fuaran_ui/__init__.py ADDED
@@ -0,0 +1,113 @@
1
+ """fuaran-ui — a headless Python host of the Fuaran UI wire format.
2
+
3
+ A dependency-light, idiomatic-Python reference implementation of the canonical
4
+ Fuaran UI wire format (``WIRE_FORMAT.md``): decode / encode for the ``Node`` tree
5
+ and the ``TreeOp`` algebra, plus a pre-emit validator. It is a *sibling*
6
+ reference implementation built to the language-neutral spec + conformance
7
+ corpus, not a transpile of any other host.
8
+
9
+ Canonical imports::
10
+
11
+ from fuaran_ui import decode_node, encode_node, decode_op, encode_op
12
+ from fuaran_ui import decode_dag_record, encode_dag_record # branching op-stream
13
+ from fuaran_ui import Ok, Err, DecodeError
14
+ from fuaran_ui.renderer import render_html # optional headless renderer
15
+ from fuaran_ui.client import FuaranClient, FuaranSession # generation-endpoint client
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from .dag import DagOpRecord, DagResultEnvelope, decode_dag_record, encode_dag_record
21
+ from .function import (
22
+ EXACT,
23
+ SUBSUMES,
24
+ ComposePath,
25
+ FunctionEntry,
26
+ FunctionRegistry,
27
+ NoPath,
28
+ SigEntry,
29
+ Signature,
30
+ SignatureQuery,
31
+ function_entry,
32
+ slot_hole,
33
+ value_hole,
34
+ )
35
+ from .merge import (
36
+ MergeAuthor,
37
+ MergeConflict,
38
+ MergeConflicts,
39
+ MergeOk,
40
+ MergeResult,
41
+ MergeSide,
42
+ Primary,
43
+ Secondary,
44
+ encode_envelope,
45
+ merge3,
46
+ merge3_way_lenient,
47
+ merge3_way_with_author,
48
+ merge_3way,
49
+ )
50
+ from .model import Arr, Node, Obj, from_json
51
+ from .ops import decode_op, diff, encode_op
52
+ from .result import (
53
+ CODES,
54
+ DecodeError,
55
+ DecodeResult,
56
+ Err,
57
+ Ok,
58
+ )
59
+ from .schema import decode_node, encode_node
60
+ from .validator import Finding, validate_node
61
+
62
+ #: Distribution version. Kept in step with ``pyproject.toml``; the packaging-contract
63
+ #: test in ``tests/`` fails if the two disagree, so this is a pin rather than a copy.
64
+ __version__ = "0.7.0"
65
+
66
+ __all__ = [
67
+ "decode_node",
68
+ "encode_node",
69
+ "decode_op",
70
+ "encode_op",
71
+ "decode_dag_record",
72
+ "encode_dag_record",
73
+ "DagOpRecord",
74
+ "DagResultEnvelope",
75
+ "merge_3way",
76
+ "merge3",
77
+ "merge3_way_with_author",
78
+ "merge3_way_lenient",
79
+ "MergeOk",
80
+ "MergeConflicts",
81
+ "MergeConflict",
82
+ "MergeSide",
83
+ "MergeResult",
84
+ "encode_envelope",
85
+ "MergeAuthor",
86
+ "Primary",
87
+ "Secondary",
88
+ "diff",
89
+ "FunctionRegistry",
90
+ "FunctionEntry",
91
+ "SigEntry",
92
+ "Signature",
93
+ "SignatureQuery",
94
+ "ComposePath",
95
+ "NoPath",
96
+ "value_hole",
97
+ "slot_hole",
98
+ "function_entry",
99
+ "SUBSUMES",
100
+ "EXACT",
101
+ "validate_node",
102
+ "Finding",
103
+ "Node",
104
+ "Obj",
105
+ "Arr",
106
+ "from_json",
107
+ "Ok",
108
+ "Err",
109
+ "DecodeError",
110
+ "DecodeResult",
111
+ "CODES",
112
+ "__version__",
113
+ ]
@@ -0,0 +1,86 @@
1
+ """``fuaran_ui.ai_tools`` — the AI-tools introspection surface.
2
+
3
+ The runtime introspection an orchestrator/agent needs to drive the Fuaran tree
4
+ natively from Python (the language AI agents are written in), over the Phase 234
5
+ headless codec and typed surface. Three parts, all provider-agnostic and
6
+ standard-library-only:
7
+
8
+ * :mod:`~fuaran_ui.ai_tools.surface` — the **emittable-surface catalog**: what an
9
+ agent may emit (recognised kinds / bindings / actions / text-sources), the
10
+ bounded **value-space** projections, provider-agnostic **tool/function schemas**
11
+ it registers, and :func:`~fuaran_ui.ai_tools.surface.validate_emission` to check
12
+ an emission against the typed surface.
13
+ * :mod:`~fuaran_ui.ai_tools.introspect` — read-only **tree introspection**: kind,
14
+ bound binding slots (with their canonical wire-form expression), and structure.
15
+ * :mod:`~fuaran_ui.ai_tools.dispatch` — the **default-deny-by-shape dispatch gate**
16
+ (FGP 3): an effect shape the host has not permitted is refused.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .dispatch import (
22
+ GATED_EFFECT_SHAPES,
23
+ INERT_SHAPES,
24
+ DispatchDecision,
25
+ DispatchGate,
26
+ is_gated_effect,
27
+ )
28
+ from .introspect import (
29
+ BindingSlot,
30
+ NodeIntrospection,
31
+ TreeIntrospection,
32
+ binding_expression,
33
+ binding_slots,
34
+ child_ids,
35
+ child_nodes,
36
+ find_node,
37
+ inspect_tree,
38
+ kind_name,
39
+ node_state,
40
+ walk_nodes,
41
+ )
42
+ from .surface import (
43
+ ValidationResult,
44
+ action_cases,
45
+ binding_cases,
46
+ describe_surface,
47
+ emit_tool_schema,
48
+ emittable_kinds,
49
+ text_source_cases,
50
+ tool_schemas,
51
+ validate_emission,
52
+ value_space,
53
+ )
54
+
55
+ __all__ = [
56
+ # surface
57
+ "emittable_kinds",
58
+ "binding_cases",
59
+ "action_cases",
60
+ "text_source_cases",
61
+ "value_space",
62
+ "describe_surface",
63
+ "validate_emission",
64
+ "ValidationResult",
65
+ "tool_schemas",
66
+ "emit_tool_schema",
67
+ # introspect
68
+ "kind_name",
69
+ "binding_expression",
70
+ "binding_slots",
71
+ "child_nodes",
72
+ "child_ids",
73
+ "walk_nodes",
74
+ "find_node",
75
+ "node_state",
76
+ "inspect_tree",
77
+ "BindingSlot",
78
+ "NodeIntrospection",
79
+ "TreeIntrospection",
80
+ # dispatch
81
+ "DispatchGate",
82
+ "DispatchDecision",
83
+ "is_gated_effect",
84
+ "GATED_EFFECT_SHAPES",
85
+ "INERT_SHAPES",
86
+ ]
@@ -0,0 +1,99 @@
1
+ """Default-deny-by-shape dispatch gate (FGP 3).
2
+
3
+ An agent driving the tree proposes ``Action`` effects; the host decides whether
4
+ each may run. This is the policy gate the introspection/dispatch path consults
5
+ **by shape** — the action's wire discriminator (`$type`) — before any effectful
6
+ action is dispatched. It is **default-deny**: an effect shape the host has not
7
+ explicitly permitted is refused, so a new or unexpected effect can never fire by
8
+ omission (the same posture as the reference tier's runtime dispatch gate and the
9
+ capability seam's default-deny-by-shape validation).
10
+
11
+ The gate is a *policy* only — it never executes an action (a Python host executes
12
+ the permitted effect downstream). It classifies an action's shape and returns an
13
+ allow/deny decision with a reason.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+
20
+ from ..model import Obj
21
+ from ..schema.decode import ACTION_CASES
22
+
23
+ # Effectful shapes that reach outside the pure state-update loop — they require
24
+ # explicit host permission (mirrors the reference runtime's gated set:
25
+ # call/navigate/ai-tool/read-file-body, plus the host-effect notify / clipboard /
26
+ # capability-invoke). An effect not in this set is still default-denied unless
27
+ # permitted; this set is what a host reasons about when granting.
28
+ GATED_EFFECT_SHAPES = frozenset(
29
+ {"Dispatch", "Navigate", "AiTool", "ReadFileBody", "Notify", "WriteToClipboard", "Invoke", "Call"}
30
+ )
31
+ # `Call` joined the set in 0.3.0. It was named FIRST in the comment above from the
32
+ # day this module was written ("call/navigate/ai-tool/read-file-body") and absent
33
+ # from the set itself — a transcription slip rather than a policy, and one nothing
34
+ # caught because `authorize` default-denies an unclassified shape anyway, so the
35
+ # gate's VERDICT was already right. What was wrong is what a host reasoning with
36
+ # `is_gated_effect` was told: that an HTTP call to a host endpoint is not an
37
+ # outward effect it must decide about. A permission model is only as good as the
38
+ # question it is asked, and that one was being asked wrong.
39
+
40
+ # Structural, side-effect-free composition — safe to permit broadly. ``Chain`` is
41
+ # a sequence (its members are gated individually); ``SetState`` mutates only the
42
+ # local MVU state.
43
+ INERT_SHAPES = frozenset({"Chain", "SetState"})
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class DispatchDecision:
48
+ """The gate's verdict for one action shape."""
49
+
50
+ shape: str
51
+ allowed: bool
52
+ reason: str
53
+
54
+
55
+ def is_gated_effect(shape: str) -> bool:
56
+ """Whether a shape is an outward/host effect that must be explicitly permitted."""
57
+ return shape in GATED_EFFECT_SHAPES
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class DispatchGate:
62
+ """A default-deny-by-shape policy gate.
63
+
64
+ ``DispatchGate()`` denies every action shape. A host opts shapes in explicitly
65
+ with :meth:`permitting`; :meth:`permissive_inert` grants only the side-effect-free
66
+ structural shapes (``Chain`` / ``SetState``), leaving every outward effect denied.
67
+ """
68
+
69
+ allowed: frozenset[str] = field(default_factory=frozenset)
70
+
71
+ @classmethod
72
+ def deny_all(cls) -> DispatchGate:
73
+ return cls(frozenset())
74
+
75
+ @classmethod
76
+ def permitting(cls, *shapes: str) -> DispatchGate:
77
+ return cls(frozenset(shapes))
78
+
79
+ @classmethod
80
+ def permissive_inert(cls) -> DispatchGate:
81
+ """Permit only the inert structural shapes; every gated effect stays denied."""
82
+ return cls(frozenset(INERT_SHAPES))
83
+
84
+ def with_permitted(self, *shapes: str) -> DispatchGate:
85
+ return DispatchGate(self.allowed | frozenset(shapes))
86
+
87
+ def authorize_shape(self, shape: str) -> DispatchDecision:
88
+ """The verdict for a bare action-shape string (default-deny)."""
89
+ if shape not in ACTION_CASES:
90
+ return DispatchDecision(shape, False, f"unknown action shape {shape!r}")
91
+ if shape in self.allowed:
92
+ return DispatchDecision(shape, True, "explicitly permitted")
93
+ return DispatchDecision(shape, False, "default-deny by shape (not permitted)")
94
+
95
+ def authorize(self, action: Obj) -> DispatchDecision:
96
+ """The verdict for a decoded ``Action`` object (reads its ``$type`` tag)."""
97
+ if not isinstance(action, Obj) or action.tag is None:
98
+ return DispatchDecision("<malformed>", False, "not a discriminated action object")
99
+ return self.authorize_shape(action.tag)
@@ -0,0 +1,190 @@
1
+ """Tree introspection — walk a decoded ``Node`` tree and report each node's kind,
2
+ its bound binding slots (with the canonical wire-form expression), and its
3
+ structural children.
4
+
5
+ The Python analogue of the reference tier's read-only introspection surface: the
6
+ *static* view an agent uses to inspect a tree it (or the model) emitted — kind
7
+ discriminator, which slots are data-bound and how (`$state.<key>` / `$queries.<name>`
8
+ / …), and the structural shape. Value resolution against a live host + geometry
9
+ probing are a renderer-side concern and are deliberately out of scope here (this
10
+ is the source-side static surface, matching the reference tier's split).
11
+
12
+ It walks the generic decoded model (``Node`` / ``Obj`` / ``Arr``) rather than a
13
+ per-kind table, so a new ``NodeKind`` needs no change here — a bound slot is any
14
+ field whose value is a ``Binding``-tagged object, discovered structurally.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+
21
+ from ..model import Arr, Node, Obj
22
+ from ..model import Value as WireValue
23
+ from ..schema.decode import BINDING_CASES
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class BindingSlot:
28
+ """One data-bound slot on a node: its dotted field path within the kind, the
29
+ ``Binding`` case that produced it, and the canonical wire-form expression."""
30
+
31
+ slot: str
32
+ source: str
33
+ expression: str
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class NodeIntrospection:
38
+ """The per-node introspection envelope."""
39
+
40
+ id: str
41
+ kind: str
42
+ bindings: tuple[BindingSlot, ...]
43
+ child_ids: tuple[str, ...]
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class TreeIntrospection:
48
+ """A recursive structural snapshot of a whole tree."""
49
+
50
+ id: str
51
+ kind: str
52
+ bindings: tuple[BindingSlot, ...]
53
+ child_ids: tuple[str, ...]
54
+ children: tuple[TreeIntrospection, ...]
55
+
56
+
57
+ def kind_name(node: Node) -> str:
58
+ """The wire discriminator for a node's kind (the ``$type`` tag) — e.g. ``Box``,
59
+ ``Metric``, ``DataGrid``."""
60
+ return node.kind.tag or "<untagged>"
61
+
62
+
63
+ def binding_expression(binding: Obj) -> tuple[str, str]:
64
+ """Classify a ``Binding``-tagged object into ``(source, expression)`` — the
65
+ canonical wire-form accessor the reference tier reports (`$state.<key>`, …)."""
66
+ tag = binding.tag or ""
67
+ fields = binding.fields
68
+ if tag == "Static":
69
+ return ("Static", "$static")
70
+ if tag == "Query":
71
+ return ("Query", f"$queries.{fields.get('name', '')}")
72
+ if tag == "Filter":
73
+ return ("Filter", f"$filters.{fields.get('name', '')}")
74
+ if tag == "Selection":
75
+ return ("Selection", f"$selection.{fields.get('nodeId', fields.get('sourceId', ''))}")
76
+ if tag == "State":
77
+ return ("State", f"$state.{fields.get('key', '')}")
78
+ if tag == "I18n":
79
+ return ("I18n", f"$i18n.{fields.get('key', '')}")
80
+ if tag == "Local":
81
+ return ("Computed", "$local")
82
+ if tag == "Format":
83
+ return ("Computed", "$format")
84
+ if tag == "Transform":
85
+ return ("Computed", "$transform")
86
+ if tag == "Invoke":
87
+ return ("Computed", "$invoke")
88
+ # Computed + any future value-computing case.
89
+ return ("Computed", "$computed")
90
+
91
+
92
+ def _is_binding(value: WireValue) -> bool:
93
+ return isinstance(value, Obj) and value.tag in BINDING_CASES
94
+
95
+
96
+ def binding_slots(node: Node) -> tuple[BindingSlot, ...]:
97
+ """Every data-bound slot in the node's kind, in document order, with a dotted
98
+ field path (e.g. ``source``, ``spec.activeIndex``). Recurses through record
99
+ objects + arrays but **not** into a nested ``Node`` (those belong to the child)."""
100
+ slots: list[BindingSlot] = []
101
+
102
+ def walk(value: WireValue, path: str) -> None:
103
+ if isinstance(value, Node):
104
+ return # a child node's bindings are the child's, not this node's
105
+ if isinstance(value, Obj):
106
+ if _is_binding(value):
107
+ source, expr = binding_expression(value)
108
+ slots.append(BindingSlot(slot=path, source=source, expression=expr))
109
+ return
110
+ for name, field_value in value.fields.items():
111
+ walk(field_value, f"{path}.{name}" if path else name)
112
+ elif isinstance(value, Arr):
113
+ for i, item in enumerate(value.items):
114
+ walk(item, f"{path}[{i}]")
115
+
116
+ for name, field_value in node.kind.fields.items():
117
+ walk(field_value, name)
118
+ return tuple(slots)
119
+
120
+
121
+ def child_nodes(node: Node) -> tuple[Node, ...]:
122
+ """The immediate structural child nodes — every ``Node`` reachable within the
123
+ kind without passing through another ``Node`` (so grandchildren are excluded)."""
124
+ children: list[Node] = []
125
+
126
+ def walk(value: WireValue) -> None:
127
+ if isinstance(value, Node):
128
+ children.append(value) # stop — its own children are its concern
129
+ elif isinstance(value, Obj):
130
+ for field_value in value.fields.values():
131
+ walk(field_value)
132
+ elif isinstance(value, Arr):
133
+ for item in value.items:
134
+ walk(item)
135
+
136
+ for field_value in node.kind.fields.values():
137
+ walk(field_value)
138
+ return tuple(children)
139
+
140
+
141
+ def child_ids(node: Node) -> tuple[str, ...]:
142
+ return tuple(c.id for c in child_nodes(node))
143
+
144
+
145
+ def walk_nodes(tree: Node) -> list[Node]:
146
+ """Depth-first walk of every node in the tree, root first."""
147
+ acc: list[Node] = []
148
+
149
+ def visit(node: Node) -> None:
150
+ acc.append(node)
151
+ for child in child_nodes(node):
152
+ visit(child)
153
+
154
+ visit(tree)
155
+ return acc
156
+
157
+
158
+ def find_node(tree: Node, node_id: str) -> Node | None:
159
+ """The first node with ``node_id`` (depth-first), or ``None``."""
160
+ for node in walk_nodes(tree):
161
+ if node.id == node_id:
162
+ return node
163
+ return None
164
+
165
+
166
+ def _introspect(node: Node) -> NodeIntrospection:
167
+ return NodeIntrospection(
168
+ id=node.id,
169
+ kind=kind_name(node),
170
+ bindings=binding_slots(node),
171
+ child_ids=child_ids(node),
172
+ )
173
+
174
+
175
+ def node_state(tree: Node, node_id: str) -> NodeIntrospection | None:
176
+ """The introspection envelope for a single node by id, or ``None``."""
177
+ node = find_node(tree, node_id)
178
+ return None if node is None else _introspect(node)
179
+
180
+
181
+ def inspect_tree(tree: Node) -> TreeIntrospection:
182
+ """A recursive structural snapshot of the whole tree."""
183
+ base = _introspect(tree)
184
+ return TreeIntrospection(
185
+ id=base.id,
186
+ kind=base.kind,
187
+ bindings=base.bindings,
188
+ child_ids=base.child_ids,
189
+ children=tuple(inspect_tree(c) for c in child_nodes(tree)),
190
+ )
@@ -0,0 +1,194 @@
1
+ """The emittable-surface catalog + provider-agnostic tool schemas.
2
+
3
+ So a Python agent can **discover what it may emit** (the recognised ``NodeKind`` /
4
+ ``Binding`` / ``Action`` / ``TextSource`` cases and the bounded value-space
5
+ projections) and **validate its intent** against the typed surface, instead of
6
+ blind-emitting JSON. The catalog is derived from the codec's own recognised-case
7
+ sets + the bounded ``Literal`` enums, so it can never drift from what the decoder
8
+ accepts.
9
+
10
+ The tool schemas are **provider-agnostic** function/tool definitions (``name`` +
11
+ ``description`` + JSON-Schema ``parameters``) an agent registers with any provider.
12
+ The primary constrained-emission tool takes the caller-supplied canonical wire
13
+ schema (``schema.json``) as its input schema, so the package stays corpus-free and
14
+ standard-library-only at import time.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import typing
20
+ from dataclasses import dataclass
21
+ from typing import Any
22
+
23
+ from ..result import Err
24
+ from ..schema import types as t
25
+ from ..schema.decode import ACTION_CASES, BINDING_CASES, KNOWN_KINDS, TEXT_SOURCE_CASES, decode_node
26
+
27
+ # The bounded value-space projections — each bare-string ``Literal`` enum an agent
28
+ # must pick within (WIRE_FORMAT §3.5). Derived from the typed surface via
29
+ # ``typing.get_args`` so the projection cannot drift from the codec.
30
+ _VALUE_SPACE_ENUMS: dict[str, Any] = {
31
+ "Tone": t.Tone,
32
+ "Weight": t.Weight,
33
+ "Emphasis": t.Emphasis,
34
+ "Orientation": t.Orientation,
35
+ "BadgeVariant": t.BadgeVariant,
36
+ "HeadingVariant": t.HeadingVariant,
37
+ "ButtonVariant": t.ButtonVariant,
38
+ "ChartKind": t.ChartKind,
39
+ "StyleRole": t.StyleRole,
40
+ "FontVoice": t.FontVoice,
41
+ "LiveRegion": t.LiveRegion,
42
+ "ImageVariant": t.ImageVariant,
43
+ # fuaran#1077 — the three presentation token vocabularies. Offered as value
44
+ # spaces precisely because they are CLOSED: an agent handed the projection
45
+ # cannot reach for a CSS ratio the decoder refuses.
46
+ "ImageFit": t.ImageFit,
47
+ "ImageAspect": t.ImageAspect,
48
+ "ImageLoading": t.ImageLoading,
49
+ "ScrollOrientation": t.ScrollOrientation,
50
+ "DateVariant": t.DateVariant,
51
+ "MathDisplay": t.MathDisplay,
52
+ # fuaran#867 — the two-case polarity enum. `Neutral` is reserved and is
53
+ # deliberately absent from the alias, so an agent offered this projection
54
+ # cannot pick a case the decoder refuses.
55
+ "TrendPolarity": t.TrendPolarity,
56
+ "BoxRole": t.BoxRole,
57
+ "FileReadEncoding": t.FileReadEncoding,
58
+ }
59
+
60
+
61
+ def emittable_kinds() -> list[str]:
62
+ """The recognised ``NodeKind`` discriminators an agent may emit."""
63
+ return sorted(KNOWN_KINDS)
64
+
65
+
66
+ def binding_cases() -> list[str]:
67
+ """The recognised ``Binding`` value-source discriminators."""
68
+ return sorted(BINDING_CASES)
69
+
70
+
71
+ def action_cases() -> list[str]:
72
+ """The recognised ``Action`` effect discriminators."""
73
+ return sorted(ACTION_CASES)
74
+
75
+
76
+ def text_source_cases() -> list[str]:
77
+ """The recognised ``TextSource`` discriminators."""
78
+ return sorted(TEXT_SOURCE_CASES)
79
+
80
+
81
+ def value_space() -> dict[str, list[str]]:
82
+ """Each bounded ``Literal`` enum → its allowed values (the within-bounds
83
+ choices an agent picks from), derived from the typed surface."""
84
+ return {name: list(typing.get_args(alias)) for name, alias in _VALUE_SPACE_ENUMS.items()}
85
+
86
+
87
+ def describe_surface() -> dict[str, Any]:
88
+ """A machine-readable snapshot of the whole emittable surface — the payload the
89
+ ``list_surface`` tool returns to an agent."""
90
+ return {
91
+ "kinds": emittable_kinds(),
92
+ "bindings": binding_cases(),
93
+ "actions": action_cases(),
94
+ "textSources": text_source_cases(),
95
+ "valueSpace": value_space(),
96
+ }
97
+
98
+
99
+ # ── Validation of an agent's emission against the typed surface ───────────────
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class ValidationResult:
104
+ """The verdict for a candidate wire tree: ``ok`` plus the canonical decode
105
+ error (code / path / message) when it is rejected."""
106
+
107
+ ok: bool
108
+ code: str | None = None
109
+ path: str | None = None
110
+ message: str | None = None
111
+
112
+
113
+ def validate_emission(wire_json: str) -> ValidationResult:
114
+ """Validate an agent's candidate ``Node`` wire JSON against the typed surface via
115
+ the Phase 234 codec — the canonical accept/reject decision + error location, so
116
+ the agent repairs intent instead of shipping malformed JSON downstream."""
117
+ result = decode_node(wire_json)
118
+ if isinstance(result, Err):
119
+ e = result.error
120
+ return ValidationResult(ok=False, code=e.code, path=e.path, message=e.message)
121
+ return ValidationResult(ok=True)
122
+
123
+
124
+ # ── Provider-agnostic tool/function-call schemas ─────────────────────────────
125
+
126
+
127
+ def emit_tool_schema(wire_schema: dict[str, Any]) -> dict[str, Any]:
128
+ """The constrained-emission tool: emit a canonical Fuaran wire tree. Takes the
129
+ caller-supplied canonical wire schema (``wire-format-fixtures/schema.json``) as
130
+ its ``parameters`` so a provider constrains generation to schema-valid wire.
131
+ Passed in (not bundled) so this package stays corpus-free + stdlib-only."""
132
+ return {
133
+ "name": "fuaran_emit_tree",
134
+ "description": (
135
+ "Emit a Fuaran UI as a canonical wire-format JSON tree (a Node). The tree "
136
+ "renders on any conformant host. Emit only recognised kinds/bindings/actions "
137
+ "and pick bounded enum values within their value-space (call fuaran_list_surface)."
138
+ ),
139
+ "parameters": wire_schema,
140
+ }
141
+
142
+
143
+ def tool_schemas() -> list[dict[str, Any]]:
144
+ """The provider-agnostic discovery/validation tool definitions an agent registers.
145
+ (The constrained-emission tool is :func:`emit_tool_schema` — it needs the wire
146
+ schema passed in.) Each is a neutral ``{name, description, parameters}`` object
147
+ usable with any provider's function-calling API."""
148
+ return [
149
+ {
150
+ "name": "fuaran_list_surface",
151
+ "description": (
152
+ "List the emittable Fuaran surface: recognised NodeKind / Binding / Action / "
153
+ "TextSource discriminators and the bounded value-space (enum -> allowed values). "
154
+ "Call before emitting to stay within the typed surface."
155
+ ),
156
+ "parameters": {"type": "object", "properties": {}, "additionalProperties": False},
157
+ },
158
+ {
159
+ "name": "fuaran_validate_tree",
160
+ "description": (
161
+ "Validate a candidate Fuaran wire tree against the typed surface. Returns ok, or "
162
+ "the canonical decode error code + path so the emission can be repaired."
163
+ ),
164
+ "parameters": {
165
+ "type": "object",
166
+ "properties": {
167
+ "tree": {
168
+ "type": "string",
169
+ "description": "The candidate Node wire JSON to validate.",
170
+ }
171
+ },
172
+ "required": ["tree"],
173
+ "additionalProperties": False,
174
+ },
175
+ },
176
+ {
177
+ "name": "fuaran_inspect_tree",
178
+ "description": (
179
+ "Introspect an emitted Fuaran wire tree: report each node's id, kind, bound "
180
+ "binding slots (with their $state/$queries/... expression), and structural children."
181
+ ),
182
+ "parameters": {
183
+ "type": "object",
184
+ "properties": {
185
+ "tree": {
186
+ "type": "string",
187
+ "description": "The Node wire JSON to introspect.",
188
+ }
189
+ },
190
+ "required": ["tree"],
191
+ "additionalProperties": False,
192
+ },
193
+ },
194
+ ]