process-geometry 0.0.3__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 (63) hide show
  1. aeg_shakespeare/__init__.py +52 -0
  2. aeg_shakespeare/_legacy_api.py +299 -0
  3. aeg_shakespeare/analysis/__init__.py +12 -0
  4. aeg_shakespeare/analysis/abelian.py +69 -0
  5. aeg_shakespeare/analysis/algebraic.py +11 -0
  6. aeg_shakespeare/analysis/am.py +19 -0
  7. aeg_shakespeare/analysis/connection.py +74 -0
  8. aeg_shakespeare/analysis/decomposition.py +37 -0
  9. aeg_shakespeare/analysis/module.py +5 -0
  10. aeg_shakespeare/central.py +20 -0
  11. aeg_shakespeare/constraints.py +121 -0
  12. aeg_shakespeare/construction.py +303 -0
  13. aeg_shakespeare/core.py +59 -0
  14. aeg_shakespeare/cost.py +51 -0
  15. aeg_shakespeare/discovery/__init__.py +53 -0
  16. aeg_shakespeare/discovery/coefficient_extension.py +75 -0
  17. aeg_shakespeare/discovery/polynomial.py +342 -0
  18. aeg_shakespeare/discovery/selection.py +146 -0
  19. aeg_shakespeare/discovery/structured.py +201 -0
  20. aeg_shakespeare/families.py +31 -0
  21. aeg_shakespeare/frame.py +5 -0
  22. aeg_shakespeare/function_theory/__init__.py +100 -0
  23. aeg_shakespeare/function_theory/abel_jacobi.py +246 -0
  24. aeg_shakespeare/function_theory/abelian.py +129 -0
  25. aeg_shakespeare/function_theory/algebraic.py +105 -0
  26. aeg_shakespeare/function_theory/am.py +266 -0
  27. aeg_shakespeare/function_theory/intersection.py +321 -0
  28. aeg_shakespeare/function_theory/module.py +118 -0
  29. aeg_shakespeare/function_theory/period_matrix.py +155 -0
  30. aeg_shakespeare/function_theory/periods.py +216 -0
  31. aeg_shakespeare/function_theory/real_branch_cycles.py +286 -0
  32. aeg_shakespeare/function_theory/weierstrass.py +136 -0
  33. aeg_shakespeare/grammar.py +225 -0
  34. aeg_shakespeare/history_geometry.py +276 -0
  35. aeg_shakespeare/linear.py +70 -0
  36. aeg_shakespeare/presentation/__init__.py +23 -0
  37. aeg_shakespeare/presentation/budget.py +27 -0
  38. aeg_shakespeare/presentation/canonicalization.py +143 -0
  39. aeg_shakespeare/presentation/constraints.py +5 -0
  40. aeg_shakespeare/presentation/construction.py +19 -0
  41. aeg_shakespeare/presentation/grammar.py +15 -0
  42. aeg_shakespeare/presentation/history.py +45 -0
  43. aeg_shakespeare/presentation/morphism.py +66 -0
  44. aeg_shakespeare/presentation/relations.py +31 -0
  45. aeg_shakespeare/presentation/search.py +31 -0
  46. aeg_shakespeare/process/__init__.py +16 -0
  47. aeg_shakespeare/process/finite/__init__.py +43 -0
  48. aeg_shakespeare/process/finite/cocycle.py +166 -0
  49. aeg_shakespeare/process/finite/families.py +318 -0
  50. aeg_shakespeare/process/history.py +53 -0
  51. aeg_shakespeare/process/local/__init__.py +7 -0
  52. aeg_shakespeare/process/local/direction.py +88 -0
  53. aeg_shakespeare/process/local/frame.py +73 -0
  54. aeg_shakespeare/process/local/system.py +43 -0
  55. aeg_shakespeare/relations.py +374 -0
  56. aeg_shakespeare/rewrite.py +157 -0
  57. aeg_shakespeare/search.py +286 -0
  58. aeg_shakespeare/signature.py +155 -0
  59. process_geometry-0.0.3.dist-info/METADATA +305 -0
  60. process_geometry-0.0.3.dist-info/RECORD +63 -0
  61. process_geometry-0.0.3.dist-info/WHEEL +5 -0
  62. process_geometry-0.0.3.dist-info/licenses/LICENSE +24 -0
  63. process_geometry-0.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,342 @@
1
+ """Bounded polynomial discovery for process observables, invariants, and quotients.
2
+
3
+ Mathematical pressure
4
+ ---------------------
5
+ The first Shakespeare layers can preserve ordered histories, exact constraints,
6
+ finite grammars, and presentation costs once a useful representation has been
7
+ proposed. A remaining gap appears earlier: classical analysis often hands the
8
+ solver a good observable or first integral before the real calculation begins.
9
+
10
+ This module provides a deliberately small exact backend for removing part of
11
+ that prior choice. It searches a bounded polynomial observer grammar, discovers
12
+ first integrals as null directions of the represented process modulo declared
13
+ constraints, and eliminates source assignments to expose exact relations among
14
+ chosen process observables.
15
+
16
+ The polynomial grammar is a search proposal language, not a claim that every
17
+ useful observer is polynomial. Likewise Groebner elimination and exact linear
18
+ algebra are discovery/certificate backends rather than Shakespeare's ontology
19
+ of process equality.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from typing import Sequence
26
+
27
+ import sympy as sp
28
+
29
+ from ..core import homogeneous_monomials
30
+ from ..presentation.constraints import AlgebraicConstraintSet
31
+ from ..process.local import ProcessSystem
32
+
33
+
34
+ def _polynomial_dict(
35
+ expr: sp.Expr,
36
+ variables: Sequence[sp.Symbol],
37
+ ) -> dict[tuple[int, ...], sp.Expr]:
38
+ try:
39
+ return sp.Poly(
40
+ sp.expand(sp.sympify(expr)),
41
+ *variables,
42
+ domain="EX",
43
+ ).as_dict()
44
+ except sp.PolynomialError as exc:
45
+ raise ValueError(
46
+ "discovery expressions must be polynomial in the declared variables"
47
+ ) from exc
48
+
49
+
50
+ def _coefficient_matrix(
51
+ expressions: Sequence[sp.Expr],
52
+ variables: Sequence[sp.Symbol],
53
+ ) -> sp.Matrix:
54
+ dictionaries = [_polynomial_dict(expression, variables) for expression in expressions]
55
+ monomials = sorted(
56
+ set().union(*(set(dictionary) for dictionary in dictionaries)),
57
+ reverse=True,
58
+ )
59
+ if not monomials:
60
+ return sp.zeros(0, len(expressions))
61
+ return sp.Matrix(
62
+ [
63
+ [dictionary.get(monomial, sp.S.Zero) for dictionary in dictionaries]
64
+ for monomial in monomials
65
+ ]
66
+ )
67
+
68
+
69
+ def _independent_polynomials(
70
+ expressions: Sequence[sp.Expr],
71
+ variables: Sequence[sp.Symbol],
72
+ ) -> tuple[sp.Expr, ...]:
73
+ basis: list[sp.Expr] = []
74
+ rank = 0
75
+ for expression in expressions:
76
+ expression = sp.expand(sp.sympify(expression))
77
+ if expression == 0:
78
+ continue
79
+ candidate = basis + [expression]
80
+ candidate_rank = int(_coefficient_matrix(candidate, variables).rank())
81
+ if candidate_rank > rank:
82
+ basis.append(expression)
83
+ rank = candidate_rank
84
+ return tuple(basis)
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class PolynomialObserverBasis:
89
+ expressions: tuple[sp.Expr, ...]
90
+ max_degree: int
91
+ raw_candidate_count: int
92
+ quotient_reduced: bool
93
+
94
+ def __post_init__(self) -> None:
95
+ if self.max_degree < 0:
96
+ raise ValueError("max_degree must be non-negative")
97
+ if self.raw_candidate_count < len(self.expressions):
98
+ raise ValueError(
99
+ "raw_candidate_count cannot be smaller than the retained basis"
100
+ )
101
+
102
+
103
+ def generate_polynomial_observer_basis(
104
+ assignments: Sequence[sp.Symbol],
105
+ *,
106
+ max_degree: int,
107
+ constraints: AlgebraicConstraintSet | None = None,
108
+ include_constant: bool = False,
109
+ ) -> PolynomialObserverBasis:
110
+ if max_degree < 0:
111
+ raise ValueError("max_degree must be non-negative")
112
+ assignments = tuple(assignments)
113
+ if not assignments and max_degree > 0:
114
+ raise ValueError("at least one assignment is required for positive degree")
115
+
116
+ if constraints is not None:
117
+ unknown = set(assignments) - set(constraints.variables)
118
+ if unknown:
119
+ raise ValueError(
120
+ "assignments missing from constraint variables: "
121
+ f"{sorted(map(str, unknown))}"
122
+ )
123
+ coordinate_variables = constraints.variables
124
+ else:
125
+ coordinate_variables = assignments
126
+
127
+ raw: list[sp.Expr] = []
128
+ start_degree = 0 if include_constant else 1
129
+ for degree in range(start_degree, max_degree + 1):
130
+ raw.extend(homogeneous_monomials(assignments, degree))
131
+
132
+ reduced = [
133
+ constraints.reduce(expression)
134
+ if constraints is not None
135
+ else sp.expand(expression)
136
+ for expression in raw
137
+ ]
138
+ independent = _independent_polynomials(reduced, coordinate_variables)
139
+ return PolynomialObserverBasis(
140
+ expressions=independent,
141
+ max_degree=max_degree,
142
+ raw_candidate_count=len(raw),
143
+ quotient_reduced=constraints is not None,
144
+ )
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class PolynomialInvariant:
149
+ expression: sp.Expr
150
+ coordinates: tuple[sp.Expr, ...]
151
+ derivative_remainder: sp.Expr
152
+
153
+ @property
154
+ def certified(self) -> bool:
155
+ return sp.expand(self.derivative_remainder) == 0
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class PolynomialInvariantDiscovery:
160
+ observer_basis: PolynomialObserverBasis
161
+ invariants: tuple[PolynomialInvariant, ...]
162
+ derivative_rank: int
163
+
164
+ @property
165
+ def nullity(self) -> int:
166
+ return len(self.observer_basis.expressions) - self.derivative_rank
167
+
168
+
169
+ def discover_polynomial_invariants(
170
+ system: ProcessSystem,
171
+ *,
172
+ constraints: AlgebraicConstraintSet | None = None,
173
+ max_degree: int,
174
+ include_constant: bool = True,
175
+ ) -> PolynomialInvariantDiscovery:
176
+ basis = generate_polynomial_observer_basis(
177
+ system.assignments,
178
+ max_degree=max_degree,
179
+ constraints=constraints,
180
+ include_constant=include_constant,
181
+ )
182
+ coordinate_variables = constraints.variables if constraints is not None else system.assignments
183
+
184
+ derivatives = tuple(
185
+ constraints.reduce(system.derive(expression))
186
+ if constraints is not None
187
+ else sp.expand(system.derive(expression))
188
+ for expression in basis.expressions
189
+ )
190
+ derivative_matrix = _coefficient_matrix(derivatives, coordinate_variables)
191
+ assignment_set = set(system.assignments)
192
+
193
+ invariants: list[PolynomialInvariant] = []
194
+ for vector in derivative_matrix.nullspace():
195
+ coordinates = tuple(sp.simplify(value) for value in vector)
196
+ expression = sp.expand(
197
+ sum(
198
+ coefficient * basis_expression
199
+ for coefficient, basis_expression in zip(coordinates, basis.expressions)
200
+ )
201
+ )
202
+ reduced_expression = constraints.reduce(expression) if constraints is not None else expression
203
+ if reduced_expression.free_symbols.isdisjoint(assignment_set):
204
+ continue
205
+ derivative_remainder = (
206
+ constraints.reduce(system.derive(expression))
207
+ if constraints is not None
208
+ else sp.expand(system.derive(expression))
209
+ )
210
+ invariants.append(
211
+ PolynomialInvariant(
212
+ expression=sp.factor(reduced_expression),
213
+ coordinates=coordinates,
214
+ derivative_remainder=sp.factor(derivative_remainder),
215
+ )
216
+ )
217
+
218
+ return PolynomialInvariantDiscovery(
219
+ observer_basis=basis,
220
+ invariants=tuple(invariants),
221
+ derivative_rank=int(derivative_matrix.rank()),
222
+ )
223
+
224
+
225
+ @dataclass(frozen=True)
226
+ class ObservableRelation:
227
+ relation: sp.Expr
228
+ pullback_remainder: sp.Expr
229
+
230
+ @property
231
+ def certified(self) -> bool:
232
+ return sp.expand(self.pullback_remainder) == 0
233
+
234
+
235
+ @dataclass(frozen=True)
236
+ class ObservableQuotient:
237
+ symbols: tuple[sp.Symbol, ...]
238
+ observables: tuple[sp.Expr, ...]
239
+ parameters: tuple[sp.Symbol, ...]
240
+ eliminated_variables: tuple[sp.Symbol, ...]
241
+ relations: tuple[ObservableRelation, ...]
242
+
243
+ @property
244
+ def complete_certificates(self) -> bool:
245
+ return all(relation.certified for relation in self.relations)
246
+
247
+
248
+ def discover_observable_relations(
249
+ observables: Sequence[sp.Expr],
250
+ symbols: Sequence[sp.Symbol],
251
+ *,
252
+ constraints: AlgebraicConstraintSet,
253
+ source_variables: Sequence[sp.Symbol],
254
+ parameters: Sequence[sp.Symbol] = (),
255
+ ) -> ObservableQuotient:
256
+ observables = tuple(sp.expand(sp.sympify(item)) for item in observables)
257
+ symbols = tuple(symbols)
258
+ source_variables = tuple(source_variables)
259
+ parameters = tuple(parameters)
260
+
261
+ if not observables or len(observables) != len(symbols):
262
+ raise ValueError("observables and symbols must be non-empty and have equal length")
263
+ if len(set(symbols)) != len(symbols):
264
+ raise ValueError("observable symbols must be distinct")
265
+ if len(set(source_variables)) != len(source_variables):
266
+ raise ValueError("source variables must be distinct")
267
+ if len(set(parameters)) != len(parameters):
268
+ raise ValueError("parameters must be distinct")
269
+
270
+ constraint_variables = set(constraints.variables)
271
+ source_set = set(source_variables)
272
+ parameter_set = set(parameters)
273
+ symbol_set = set(symbols)
274
+ if source_set & parameter_set:
275
+ raise ValueError("source variables and parameters must be disjoint")
276
+ if symbol_set & constraint_variables:
277
+ raise ValueError("observable symbols must be fresh relative to constraint variables")
278
+ if source_set | parameter_set != constraint_variables:
279
+ missing = constraint_variables - (source_set | parameter_set)
280
+ extra = (source_set | parameter_set) - constraint_variables
281
+ raise ValueError(
282
+ "source_variables + parameters must partition constraint variables; "
283
+ f"missing={sorted(map(str, missing))}, extra={sorted(map(str, extra))}"
284
+ )
285
+
286
+ generator_order = source_variables + symbols + parameters
287
+ defining_relations = list(constraints.relations)
288
+ defining_relations.extend(
289
+ sp.expand(symbol - observable)
290
+ for symbol, observable in zip(symbols, observables)
291
+ )
292
+ try:
293
+ elimination_basis = sp.groebner(
294
+ defining_relations,
295
+ *generator_order,
296
+ order="lex",
297
+ )
298
+ except sp.PolynomialError as exc:
299
+ raise ValueError("observable elimination requires polynomial relations") from exc
300
+
301
+ substitutions = dict(zip(symbols, observables))
302
+ relation_certificates: list[ObservableRelation] = []
303
+ for polynomial in elimination_basis.polys:
304
+ relation = sp.expand(polynomial.as_expr())
305
+ if relation.free_symbols & source_set:
306
+ continue
307
+ if not relation.free_symbols & symbol_set:
308
+ continue
309
+ relation = sp.factor(relation)
310
+ pullback = sp.expand(relation.subs(substitutions))
311
+ relation_certificates.append(
312
+ ObservableRelation(
313
+ relation=relation,
314
+ pullback_remainder=sp.factor(constraints.reduce(pullback)),
315
+ )
316
+ )
317
+
318
+ return ObservableQuotient(
319
+ symbols=symbols,
320
+ observables=observables,
321
+ parameters=parameters,
322
+ eliminated_variables=source_variables,
323
+ relations=tuple(relation_certificates),
324
+ )
325
+
326
+
327
+ def discover_first_order_process_quotient(
328
+ system: ProcessSystem,
329
+ observable: sp.Expr,
330
+ *,
331
+ observable_symbol: sp.Symbol,
332
+ derivative_symbol: sp.Symbol,
333
+ constraints: AlgebraicConstraintSet,
334
+ parameters: Sequence[sp.Symbol] = (),
335
+ ) -> ObservableQuotient:
336
+ return discover_observable_relations(
337
+ (sp.sympify(observable), system.derive(observable)),
338
+ (observable_symbol, derivative_symbol),
339
+ constraints=constraints,
340
+ source_variables=system.assignments,
341
+ parameters=parameters,
342
+ )
@@ -0,0 +1,146 @@
1
+ """Costed search over first-order algebraic observer quotients.
2
+
3
+ This module connects the discovery front-end to Shakespeare's multi-axis
4
+ presentation search. The task here is deliberately narrow: among a
5
+ caller-declared family of observer candidates, find those whose first process
6
+ jet ``(F, D F)`` closes by certified algebraic relations on a declared leaf,
7
+ and compare the resulting presentations without imposing a universal scalar
8
+ objective.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ import math
15
+ from typing import Callable, Sequence
16
+
17
+ import sympy as sp
18
+
19
+ from ..presentation.constraints import AlgebraicConstraintSet
20
+ from ..presentation.search import (
21
+ PresentationCandidate,
22
+ PresentationCost,
23
+ PresentationSearchResult,
24
+ pareto_frontier,
25
+ )
26
+ from ..process.local import ProcessSystem
27
+ from .polynomial import ObservableQuotient, discover_first_order_process_quotient
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class FirstOrderObservablePresentation:
32
+ """One observer together with its discovered first-order algebraic quotient."""
33
+
34
+ observable: sp.Expr
35
+ derivative: sp.Expr
36
+ quotient: ObservableQuotient
37
+
38
+ @property
39
+ def algebraically_closed(self) -> bool:
40
+ return bool(self.quotient.relations) and self.quotient.complete_certificates
41
+
42
+
43
+ def _expression_complexity(expr: sp.Expr) -> float:
44
+ expr = sp.expand(sp.sympify(expr))
45
+ return float(int(sp.count_ops(expr, visual=False)) + 1)
46
+
47
+
48
+ def _relation_complexity(
49
+ relation: sp.Expr,
50
+ variables: Sequence[sp.Symbol],
51
+ ) -> float:
52
+ """Transparent degree-plus-support proxy for one algebraic relation."""
53
+ try:
54
+ polynomial = sp.Poly(sp.expand(relation), *variables, domain="EX")
55
+ except sp.PolynomialError as exc:
56
+ raise ValueError("quotient relation must be polynomial") from exc
57
+ return float(int(polynomial.total_degree()) + len(polynomial.terms()))
58
+
59
+
60
+ def structural_first_order_quotient_cost(
61
+ presentation: FirstOrderObservablePresentation,
62
+ ) -> PresentationCost:
63
+ """Default multi-axis cost for a first-order algebraic observer presentation."""
64
+ relation_variables = (
65
+ presentation.quotient.symbols + presentation.quotient.parameters
66
+ )
67
+ relation_cost = sum(
68
+ _relation_complexity(item.relation, relation_variables)
69
+ for item in presentation.quotient.relations
70
+ )
71
+ return PresentationCost(
72
+ grammar=(
73
+ _expression_complexity(presentation.observable)
74
+ + _expression_complexity(presentation.derivative)
75
+ ),
76
+ relations=float(relation_cost),
77
+ history=1.0,
78
+ decoder=0.0,
79
+ task_error=0.0 if presentation.algebraically_closed else math.inf,
80
+ )
81
+
82
+
83
+ def _fresh_symbol(base: str, forbidden: set[sp.Symbol]) -> sp.Symbol:
84
+ candidate = sp.Symbol(base)
85
+ suffix = 0
86
+ while candidate in forbidden:
87
+ suffix += 1
88
+ candidate = sp.Symbol(f"{base}_{suffix}")
89
+ forbidden.add(candidate)
90
+ return candidate
91
+
92
+
93
+ def search_first_order_process_quotients(
94
+ system: ProcessSystem,
95
+ observer_candidates: Sequence[sp.Expr],
96
+ *,
97
+ constraints: AlgebraicConstraintSet,
98
+ parameters: Sequence[sp.Symbol] = (),
99
+ cost_model: Callable[
100
+ [FirstOrderObservablePresentation], PresentationCost
101
+ ] | None = None,
102
+ ) -> PresentationSearchResult[FirstOrderObservablePresentation]:
103
+ """Compare candidate observers by certified first-order algebraic closure."""
104
+ observer_candidates = tuple(
105
+ sp.expand(sp.sympify(candidate)) for candidate in observer_candidates
106
+ )
107
+ if not observer_candidates:
108
+ raise ValueError("at least one observer candidate is required")
109
+
110
+ score = cost_model or structural_first_order_quotient_cost
111
+ forbidden = set(constraints.variables)
112
+ evaluated: list[PresentationCandidate[FirstOrderObservablePresentation]] = []
113
+
114
+ for index, observable in enumerate(observer_candidates):
115
+ observable_symbol = _fresh_symbol(f"U{index}", forbidden)
116
+ derivative_symbol = _fresh_symbol(f"Y{index}", forbidden)
117
+ quotient = discover_first_order_process_quotient(
118
+ system,
119
+ observable,
120
+ observable_symbol=observable_symbol,
121
+ derivative_symbol=derivative_symbol,
122
+ constraints=constraints,
123
+ parameters=parameters,
124
+ )
125
+ payload = FirstOrderObservablePresentation(
126
+ observable=observable,
127
+ derivative=system.derive(observable),
128
+ quotient=quotient,
129
+ )
130
+ evaluated.append(
131
+ PresentationCandidate(
132
+ payload=payload,
133
+ cost=score(payload),
134
+ sufficient=payload.algebraically_closed,
135
+ label=str(observable),
136
+ certificate=tuple(
137
+ relation.relation for relation in quotient.relations
138
+ ),
139
+ )
140
+ )
141
+
142
+ evaluated_tuple = tuple(evaluated)
143
+ return PresentationSearchResult(
144
+ evaluated=evaluated_tuple,
145
+ pareto=pareto_frontier(evaluated_tuple),
146
+ )
@@ -0,0 +1,201 @@
1
+ """Minimal structured-observer proposals for process discovery.
2
+
3
+ This module is deliberately narrower than a general typed mathematical IR.
4
+ The first calibration only needs one fact that bare scalar assignment symbols
5
+ cannot express: several primitive objects may belong to the same pairable sort,
6
+ and a caller-supplied pairing may turn two such objects into a scalar observer.
7
+
8
+ The abstraction stops there. It does not assume vector-space addition, scalar
9
+ multiplication, bases, matrices, Fourier analysis, or a universal theory
10
+ protocol. Those structures should enter only when later mathematical
11
+ vignettes force them.
12
+
13
+ A proposal retains two layers at once:
14
+
15
+ * ``construction`` records the structured recipe, e.g. ``pair(q,e)``;
16
+ * ``expression`` is the scalar backend lowering consumed by the existing
17
+ polynomial discovery layer.
18
+
19
+ Thus a coordinate expression such as ``qy`` need not become the ontology of
20
+ an observer whose actual construction is ``pair(q,e)``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+ from itertools import combinations_with_replacement, product
27
+ from typing import Callable, Sequence
28
+
29
+ import sympy as sp
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class PairableAtom:
34
+ """One named structured atom with a backend component realization.
35
+
36
+ ``sort`` is intentionally only an equality tag. The current layer knows no
37
+ axioms associated with that tag; it merely prevents a pairing from being
38
+ applied across incompatible primitive kinds.
39
+ """
40
+
41
+ name: str
42
+ components: tuple[sp.Expr, ...]
43
+ sort: str = "pairable"
44
+
45
+ def __post_init__(self) -> None:
46
+ if not self.name:
47
+ raise ValueError("structured atom name must be non-empty")
48
+ if not self.sort:
49
+ raise ValueError("structured atom sort must be non-empty")
50
+ components = tuple(sp.expand(sp.sympify(item)) for item in self.components)
51
+ if not components:
52
+ raise ValueError("pairable atom requires at least one component")
53
+ object.__setattr__(self, "components", components)
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class PairingSpec:
58
+ """A caller-declared scalar pairing on one structured sort.
59
+
60
+ ``lower`` receives the two component tuples and returns the exact scalar
61
+ backend expression. Symmetry affects construction enumeration only; no
62
+ bilinearity, positivity, or other vector-space law is inferred.
63
+ """
64
+
65
+ name: str
66
+ sort: str
67
+ lower: Callable[[tuple[sp.Expr, ...], tuple[sp.Expr, ...]], sp.Expr]
68
+ symmetric: bool = True
69
+ cost: float = 1.0
70
+
71
+ def __post_init__(self) -> None:
72
+ if not self.name:
73
+ raise ValueError("pairing name must be non-empty")
74
+ if not self.sort:
75
+ raise ValueError("pairing sort must be non-empty")
76
+ if self.cost < 0:
77
+ raise ValueError("pairing cost must be non-negative")
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class PairingConstruction:
82
+ """Construction certificate for one scalar observer proposal."""
83
+
84
+ pairing_name: str
85
+ left_name: str
86
+ right_name: str
87
+ cost: float
88
+
89
+ def recipe(self) -> str:
90
+ return f"{self.pairing_name}({self.left_name},{self.right_name})"
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class StructuredObserverProposal:
95
+ """A scalar backend expression together with its structured origin."""
96
+
97
+ expression: sp.Expr
98
+ construction: PairingConstruction
99
+
100
+ @property
101
+ def cost(self) -> float:
102
+ return self.construction.cost
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class StructuredObserverProposalResult:
107
+ proposals: tuple[StructuredObserverProposal, ...]
108
+ rejected: tuple[str, ...] = ()
109
+
110
+
111
+ def euclidean_pairing(
112
+ *,
113
+ name: str = "pair",
114
+ sort: str = "euclidean",
115
+ cost: float = 1.0,
116
+ ) -> PairingSpec:
117
+ """Return the finite-coordinate Euclidean pairing used by calibrations.
118
+
119
+ This convenience constructor is a backend realization, not a declaration
120
+ that every pairable sort is Euclidean.
121
+ """
122
+
123
+ def lower(left: tuple[sp.Expr, ...], right: tuple[sp.Expr, ...]) -> sp.Expr:
124
+ if len(left) != len(right):
125
+ raise ValueError("pairing requires equal component dimensions")
126
+ return sp.expand(sum(a * b for a, b in zip(left, right)))
127
+
128
+ return PairingSpec(name=name, sort=sort, lower=lower, symmetric=True, cost=cost)
129
+
130
+
131
+ def generate_pairing_observers(
132
+ atoms: Sequence[PairableAtom],
133
+ pairing: PairingSpec,
134
+ ) -> StructuredObserverProposalResult:
135
+ """Generate all bounded depth-one scalar observers from one pairing.
136
+
137
+ The caller supplies the primitive atoms and the pairing itself. Shakespeare
138
+ supplies no vector-space laws beyond the explicitly declared symmetry used
139
+ to avoid duplicate recipes.
140
+ """
141
+
142
+ atoms = tuple(atoms)
143
+ if not atoms:
144
+ raise ValueError("at least one structured atom is required")
145
+
146
+ eligible = tuple(atom for atom in atoms if atom.sort == pairing.sort)
147
+ rejected = tuple(
148
+ f"{atom.name}: sort {atom.sort!r} is incompatible with pairing sort {pairing.sort!r}"
149
+ for atom in atoms
150
+ if atom.sort != pairing.sort
151
+ )
152
+ if not eligible:
153
+ return StructuredObserverProposalResult((), rejected)
154
+
155
+ pairs = (
156
+ combinations_with_replacement(eligible, 2)
157
+ if pairing.symmetric
158
+ else product(eligible, repeat=2)
159
+ )
160
+ proposals: list[StructuredObserverProposal] = []
161
+ for left, right in pairs:
162
+ try:
163
+ expression = sp.expand(
164
+ sp.sympify(pairing.lower(left.components, right.components))
165
+ )
166
+ except Exception as exc: # caller pairing boundary
167
+ rejected += (f"{pairing.name}({left.name},{right.name}): {exc}",)
168
+ continue
169
+ proposals.append(
170
+ StructuredObserverProposal(
171
+ expression=expression,
172
+ construction=PairingConstruction(
173
+ pairing_name=pairing.name,
174
+ left_name=left.name,
175
+ right_name=right.name,
176
+ cost=float(pairing.cost),
177
+ ),
178
+ )
179
+ )
180
+
181
+ return StructuredObserverProposalResult(tuple(proposals), rejected)
182
+
183
+
184
+ def nonstationary_observer_proposals(
185
+ system,
186
+ proposals: Sequence[StructuredObserverProposal],
187
+ *,
188
+ constraints,
189
+ ) -> tuple[StructuredObserverProposal, ...]:
190
+ """Keep proposals whose first process derivative survives the quotient.
191
+
192
+ This is a task filter, not an equality rule: stationary constructions remain
193
+ valid constructions, but they are insufficient for the present task of
194
+ finding a one-dimensional evolving observer.
195
+ """
196
+
197
+ return tuple(
198
+ proposal
199
+ for proposal in proposals
200
+ if not constraints.contains(system.derive(proposal.expression))
201
+ )