lattice-cli 0.2.0__tar.gz → 0.3.1__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 (44) hide show
  1. {lattice_cli-0.2.0/src/lattice_cli.egg-info → lattice_cli-0.3.1}/PKG-INFO +36 -1
  2. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/README.md +35 -0
  3. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/pyproject.toml +1 -1
  4. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/__init__.py +1 -1
  5. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/agent.py +51 -1
  6. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/cli.py +1 -1
  7. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/parser.py +73 -15
  8. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/resources/claude/lattice-workflow/SKILL.md +3 -1
  9. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/resources/codex/lattice-workflow/SKILL.md +3 -1
  10. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/resources/generic/lattice-workflow/SKILL.md +3 -1
  11. lattice_cli-0.3.1/src/lattice/schema_text.py +169 -0
  12. {lattice_cli-0.2.0 → lattice_cli-0.3.1/src/lattice_cli.egg-info}/PKG-INFO +36 -1
  13. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice_cli.egg-info/SOURCES.txt +4 -0
  14. lattice_cli-0.3.1/tests/test_constraint_type_errors.py +30 -0
  15. lattice_cli-0.3.1/tests/test_docs_examples.py +58 -0
  16. lattice_cli-0.3.1/tests/test_schema_text.py +184 -0
  17. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/LICENSE +0 -0
  18. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/setup.cfg +0 -0
  19. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/__main__.py +0 -0
  20. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/constraints.py +0 -0
  21. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/formatters.py +0 -0
  22. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/ipog.py +0 -0
  23. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/model.py +0 -0
  24. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/resources/claude/lattice-workflow/references/modeling-rules.md +0 -0
  25. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/resources/claude/lattice-workflow/references/worked-examples.md +0 -0
  26. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/resources/codex/lattice-workflow/agents/openai.yaml +0 -0
  27. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/resources/codex/lattice-workflow/references/modeling-rules.md +0 -0
  28. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/resources/codex/lattice-workflow/references/worked-examples.md +0 -0
  29. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/resources/generic/lattice-workflow/references/modeling-rules.md +0 -0
  30. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice/resources/generic/lattice-workflow/references/worked-examples.md +0 -0
  31. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice_cli.egg-info/dependency_links.txt +0 -0
  32. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice_cli.egg-info/entry_points.txt +0 -0
  33. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice_cli.egg-info/requires.txt +0 -0
  34. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/src/lattice_cli.egg-info/top_level.txt +0 -0
  35. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/tests/test_agent_bootstrap_matrix.py +0 -0
  36. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/tests/test_cli.py +0 -0
  37. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/tests/test_constraints.py +0 -0
  38. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/tests/test_examples.py +0 -0
  39. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/tests/test_ipog.py +0 -0
  40. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/tests/test_model.py +0 -0
  41. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/tests/test_parser.py +0 -0
  42. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/tests/test_schema_guidance_matrix.py +0 -0
  43. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/tests/test_self_hosting_matrix.py +0 -0
  44. {lattice_cli-0.2.0 → lattice_cli-0.3.1}/tests/test_yaml_format.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lattice-cli
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Deterministic combinatorial coverage for agent workflows and constrained variant surfaces.
5
5
  Author-email: Tyler Klose <tylerklose@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -143,6 +143,41 @@ the invalid rule needs multiple antecedents.
143
143
 
144
144
  `conditional` parameters are first-class schema features. The parser expands them into concrete parameter domains and the constraint engine enforces `N/A` semantics automatically.
145
145
 
146
+ A schema with two constraints:
147
+
148
+ ```yaml
149
+ model_name: checkout
150
+ strength: 2
151
+ parameters:
152
+ payment_method: [credit_card, paypal, apple_pay]
153
+ region: [US, UK, DE]
154
+ browser:
155
+ values: [chrome, safari, firefox]
156
+ weights: [0.6, 0.3, 0.1]
157
+ constraints:
158
+ - type: invalid_pair
159
+ if: { parameter: payment_method, value: apple_pay }
160
+ then_not: { parameter: browser, value: firefox }
161
+ - type: conditional
162
+ parameter: card_type
163
+ values: [visa, mastercard]
164
+ parent: payment_method
165
+ applies_when: [credit_card]
166
+ ```
167
+
168
+ Every constraint needs a `type` key. Run `lattice agent instructions` for an example of each type.
169
+
170
+ ### Values keep their text
171
+
172
+ Every value in a schema (the model name, parameter names and values, and constraint references) is used exactly as written. In YAML, unquoted `1.10`, `0x1F`, `.inf` or `2026-10-26T10:00:00Z` stay as typed rather than being turned into numbers or dates. The exceptions:
173
+
174
+ - Unquoted `yes`, `no`, `on`, `off`, `true` and `false` (lowercase, Capitalized or UPPERCASE) become `true` / `false`, and an empty value, `~` or `null` becomes `null`. Quote them (`"yes"`) to keep the word.
175
+ - JSON integers are written in plain decimal form (`-0` becomes `0`).
176
+
177
+ `strength` must be a whole number, and `weights` decimal numbers such as `0.7` or `1e-3`. Anchors, aliases and `<<` merge keys work; a key written twice, more than one YAML document, or a tag other than YAML's standard ones is an error.
178
+
179
+ Changed in 0.3.0: earlier versions converted unquoted numbers and dates, so `0x1F` became `31`, `1.10` became `1.1`, and some values (`.inf`, timestamps) came out differently in the Ruby and Python implementations.
180
+
146
181
  ## Solver Notes
147
182
 
148
183
  The current engine is deterministic and self-contained. If `ortools` is installed via the `solver` extra, Lattice can use CP-SAT feasibility checks for partial assignments; otherwise it falls back to the built-in backtracking solver.
@@ -115,6 +115,41 @@ the invalid rule needs multiple antecedents.
115
115
 
116
116
  `conditional` parameters are first-class schema features. The parser expands them into concrete parameter domains and the constraint engine enforces `N/A` semantics automatically.
117
117
 
118
+ A schema with two constraints:
119
+
120
+ ```yaml
121
+ model_name: checkout
122
+ strength: 2
123
+ parameters:
124
+ payment_method: [credit_card, paypal, apple_pay]
125
+ region: [US, UK, DE]
126
+ browser:
127
+ values: [chrome, safari, firefox]
128
+ weights: [0.6, 0.3, 0.1]
129
+ constraints:
130
+ - type: invalid_pair
131
+ if: { parameter: payment_method, value: apple_pay }
132
+ then_not: { parameter: browser, value: firefox }
133
+ - type: conditional
134
+ parameter: card_type
135
+ values: [visa, mastercard]
136
+ parent: payment_method
137
+ applies_when: [credit_card]
138
+ ```
139
+
140
+ Every constraint needs a `type` key. Run `lattice agent instructions` for an example of each type.
141
+
142
+ ### Values keep their text
143
+
144
+ Every value in a schema (the model name, parameter names and values, and constraint references) is used exactly as written. In YAML, unquoted `1.10`, `0x1F`, `.inf` or `2026-10-26T10:00:00Z` stay as typed rather than being turned into numbers or dates. The exceptions:
145
+
146
+ - Unquoted `yes`, `no`, `on`, `off`, `true` and `false` (lowercase, Capitalized or UPPERCASE) become `true` / `false`, and an empty value, `~` or `null` becomes `null`. Quote them (`"yes"`) to keep the word.
147
+ - JSON integers are written in plain decimal form (`-0` becomes `0`).
148
+
149
+ `strength` must be a whole number, and `weights` decimal numbers such as `0.7` or `1e-3`. Anchors, aliases and `<<` merge keys work; a key written twice, more than one YAML document, or a tag other than YAML's standard ones is an error.
150
+
151
+ Changed in 0.3.0: earlier versions converted unquoted numbers and dates, so `0x1F` became `31`, `1.10` became `1.1`, and some values (`.inf`, timestamps) came out differently in the Ruby and Python implementations.
152
+
118
153
  ## Solver Notes
119
154
 
120
155
  The current engine is deterministic and self-contained. If `ortools` is installed via the `solver` extra, Lattice can use CP-SAT feasibility checks for partial assignments; otherwise it falls back to the built-in backtracking solver.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lattice-cli"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  description = "Deterministic combinatorial coverage for agent workflows and constrained variant surfaces."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0"
5
+ __version__ = "0.3.1"
@@ -25,6 +25,56 @@ DEFAULT_PROMPT = (
25
25
  "rows; and use the rows to strengthen the plan, tests, fixtures, or visual review."
26
26
  )
27
27
 
28
+ SCHEMA_REFERENCE = """
29
+ Schema format, with one example of each constraint type:
30
+
31
+ ```yaml
32
+ model_name: checkout
33
+ strength: 2
34
+ parameters:
35
+ user: [guest, member]
36
+ payment: [card, paypal, apple_pay, invoice]
37
+ device: [desktop, ios, android]
38
+ region: [us, eu]
39
+ currency: [usd, eur]
40
+ shipping: [standard, express, pickup]
41
+ constraints:
42
+ # invalid_pair: these two assignments never appear in the same row.
43
+ - type: invalid_pair
44
+ if: { parameter: payment, value: apple_pay }
45
+ then_not: { parameter: device, value: android }
46
+ # bidirectional: each assignment requires the other.
47
+ - type: bidirectional
48
+ pair:
49
+ - { parameter: region, value: eu }
50
+ - { parameter: currency, value: eur }
51
+ # forward_dep: the source requires the target; the target may appear elsewhere.
52
+ - type: forward_dep
53
+ source: { parameter: shipping, value: pickup }
54
+ target: { parameter: region, value: us }
55
+ # conditional: defines a child parameter that only applies for some parent values.
56
+ - type: conditional
57
+ parameter: card_type
58
+ values: [visa, mastercard]
59
+ parent: payment
60
+ applies_when: [card]
61
+ # forced: this combination must appear in at least one row.
62
+ - type: forced
63
+ assignments:
64
+ - { parameter: user, value: guest }
65
+ - { parameter: device, value: ios }
66
+ - { parameter: shipping, value: express }
67
+ # higher_order: the last assignment is excluded only when all of if_all hold.
68
+ - type: higher_order
69
+ if_all:
70
+ - { parameter: user, value: guest }
71
+ - { parameter: region, value: eu }
72
+ then_not: { parameter: shipping, value: pickup }
73
+ ```
74
+
75
+ Every constraint needs a `type` key. Each assignment is `{ parameter: <name>, value: <value> }`.
76
+ """
77
+
28
78
  GENERIC_AGENT_INSTRUCTIONS = f"""# Lattice Agent Instructions
29
79
 
30
80
  Lattice is harness-agnostic. Use these instructions in OpenClaw, Hermes, Codex, xAI-backed agents, Claude Code, or any agent that can run shell commands.
@@ -49,7 +99,7 @@ Prefer JSON or YAML on stdin:
49
99
  cat schema.json | lattice validate
50
100
  cat schema.json | lattice generate
51
101
  ```
52
- """
102
+ """ + SCHEMA_REFERENCE
53
103
 
54
104
 
55
105
  @dataclass(frozen=True)
@@ -389,7 +389,7 @@ def _run_agent_command(args: argparse.Namespace) -> int:
389
389
  )
390
390
  )
391
391
  else:
392
- print(GENERIC_AGENT_INSTRUCTIONS)
392
+ print(GENERIC_AGENT_INSTRUCTIONS, end="")
393
393
  return 0
394
394
 
395
395
  if args.agent_command == "doctor":
@@ -1,13 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
3
+ import math
4
+ import re
4
5
  import sys
5
6
  from collections import OrderedDict
6
7
  from pathlib import Path
7
8
  from typing import Any
8
9
 
9
- import yaml
10
-
11
10
  from lattice.model import (
12
11
  Assignment,
13
12
  BidirectionalConstraint,
@@ -21,6 +20,7 @@ from lattice.model import (
21
20
  normalize_scalar,
22
21
  NA_VALUE,
23
22
  )
23
+ from lattice.schema_text import load_json, load_yaml
24
24
 
25
25
 
26
26
  class ModelIOError(Exception):
@@ -88,11 +88,11 @@ def _read_model_input(path: str | None) -> tuple[str, str, str | None]:
88
88
  def _deserialize_model(text: str, source_name: str, extension: str | None) -> Any:
89
89
  loaders = []
90
90
  if extension == ".json":
91
- loaders = [json.loads]
91
+ loaders = [load_json]
92
92
  elif extension in {".yaml", ".yml"}:
93
- loaders = [yaml.safe_load]
93
+ loaders = [load_yaml]
94
94
  else:
95
- loaders = [json.loads, yaml.safe_load]
95
+ loaders = [load_json, load_yaml]
96
96
 
97
97
  errors: list[str] = []
98
98
  for loader in loaders:
@@ -204,6 +204,12 @@ def _parse_constraints(
204
204
  continue
205
205
 
206
206
  constraint_type = raw_constraint.get("type")
207
+ if constraint_type is None:
208
+ errors.append(_missing_type_message(index, raw_constraint))
209
+ continue
210
+ if not isinstance(constraint_type, str):
211
+ errors.append(f"`constraints[{index}].type` must be a string, one of: {_CONSTRAINT_TYPE_LIST}.")
212
+ continue
207
213
  if constraint_type == "invalid_pair":
208
214
  if_assignment = _parse_assignment(raw_constraint.get("if"), parameters, f"`constraints[{index}].if`", errors)
209
215
  then_not = _parse_assignment(raw_constraint.get("then_not"), parameters, f"`constraints[{index}].then_not`", errors)
@@ -316,7 +322,7 @@ def _parse_constraints(
316
322
  parsed.append(HigherOrderConstraint(if_all=tuple(parsed_if_all), then_not=parsed_then_not))
317
323
  continue
318
324
 
319
- errors.append(f"`constraints[{index}]` has unknown type `{constraint_type}`.")
325
+ errors.append(f"`constraints[{index}]` has unknown type `{constraint_type}`. Use one of: {_CONSTRAINT_TYPE_LIST}.")
320
326
 
321
327
  return parsed
322
328
 
@@ -376,26 +382,78 @@ def _parse_weights(
376
382
  return None
377
383
 
378
384
  parsed: list[float] = []
379
- for weight in raw_weights:
380
- if not isinstance(weight, (int, float)):
385
+ for raw_weight in raw_weights:
386
+ weight = _parse_number(raw_weight)
387
+ if weight is None:
381
388
  errors.append(f"`weights` for parameter `{parameter_name}` must be numeric.")
382
389
  return None
383
390
  if weight <= 0:
384
391
  errors.append(f"`weights` for parameter `{parameter_name}` must be greater than zero.")
385
392
  return None
386
- parsed.append(float(weight))
393
+ parsed.append(weight)
387
394
  return tuple(parsed)
388
395
 
389
396
 
397
+ # Each constraint type and the keys written beside `type`.
398
+ _CONSTRAINT_FIELDS = {
399
+ "invalid_pair": "`if` and `then_not`",
400
+ "bidirectional": "`pair`",
401
+ "forward_dep": "`source` and `target`",
402
+ "conditional": "`parameter`, `values`, `parent` and `applies_when`",
403
+ "forced": "`assignments`",
404
+ "higher_order": "`if_all` and `then_not`",
405
+ }
406
+ _CONSTRAINT_TYPE_LIST = ", ".join(f"`{name}`" for name in _CONSTRAINT_FIELDS)
407
+
408
+
409
+ def _missing_type_message(index: int, raw_constraint: dict[Any, Any]) -> str:
410
+ """Explains a constraint without `type`. A type name used as a key, as in
411
+ `- invalid_pair: {...}`, gets the correct form spelled out."""
412
+ shorthand = next((key for key in raw_constraint if isinstance(key, str) and key in _CONSTRAINT_FIELDS), None)
413
+ if shorthand is None:
414
+ return f"`constraints[{index}]` is missing `type`. Use one of: {_CONSTRAINT_TYPE_LIST}."
415
+ return (
416
+ f"`constraints[{index}]` is missing `type`. Write `type: {shorthand}` as its own key, "
417
+ f"with {_CONSTRAINT_FIELDS[shorthand]} beside it. "
418
+ "Run `lattice agent instructions` for an example of each type."
419
+ )
420
+
421
+
422
+ _DECIMAL_TEXT = re.compile(r"[-+]?[0-9]+(\.[0-9]+)?([eE][-+]?[0-9]+)?")
423
+ _INTEGER_TEXT = re.compile(r"[-+]?[0-9]+")
424
+
425
+
426
+ def _parse_number(raw: Any) -> float | None:
427
+ """A finite number, from a Python number or decimal text such as "0.7" or
428
+ "1e-3" (schema files keep numbers as text). Booleans are not numbers."""
429
+ if isinstance(raw, bool):
430
+ return None
431
+ if isinstance(raw, (int, float)):
432
+ number = float(raw)
433
+ elif isinstance(raw, str) and _DECIMAL_TEXT.fullmatch(raw):
434
+ number = float(raw)
435
+ else:
436
+ return None
437
+ return number if math.isfinite(number) else None
438
+
439
+
390
440
  def _parse_strength(raw_strength: Any, parameter_count: int, errors: list[str]) -> int:
441
+ strength: int | None
391
442
  if raw_strength is None:
392
443
  strength = 2
444
+ elif isinstance(raw_strength, bool):
445
+ strength = None
446
+ elif isinstance(raw_strength, int):
447
+ strength = raw_strength
448
+ elif isinstance(raw_strength, float):
449
+ strength = int(raw_strength) if math.isfinite(raw_strength) else None
450
+ elif isinstance(raw_strength, str) and _INTEGER_TEXT.fullmatch(raw_strength):
451
+ strength = int(raw_strength, 10)
393
452
  else:
394
- try:
395
- strength = int(raw_strength)
396
- except (TypeError, ValueError):
397
- errors.append("`strength` must be an integer.")
398
- return 2
453
+ strength = None
454
+ if strength is None:
455
+ errors.append("`strength` must be an integer.")
456
+ return 2
399
457
 
400
458
  if strength < 1:
401
459
  errors.append("`strength` must be at least 1.")
@@ -35,7 +35,9 @@ parameters:
35
35
  payment: [card, apple_pay, paypal]
36
36
  cart: [empty, single, multi]
37
37
  constraints:
38
- - invalid_pair: {payment: apple_pay, cart: empty}
38
+ - type: invalid_pair
39
+ if: { parameter: payment, value: apple_pay }
40
+ then_not: { parameter: cart, value: empty }
39
41
  ```
40
42
 
41
43
  One generated row (JSON):
@@ -35,7 +35,9 @@ parameters:
35
35
  payment: [card, apple_pay, paypal]
36
36
  cart: [empty, single, multi]
37
37
  constraints:
38
- - invalid_pair: {payment: apple_pay, cart: empty}
38
+ - type: invalid_pair
39
+ if: { parameter: payment, value: apple_pay }
40
+ then_not: { parameter: cart, value: empty }
39
41
  ```
40
42
 
41
43
  One generated row (JSON):
@@ -35,7 +35,9 @@ parameters:
35
35
  payment: [card, apple_pay, paypal]
36
36
  cart: [empty, single, multi]
37
37
  constraints:
38
- - invalid_pair: {payment: apple_pay, cart: empty}
38
+ - type: invalid_pair
39
+ if: { parameter: payment, value: apple_pay }
40
+ then_not: { parameter: cart, value: empty }
39
41
  ```
40
42
 
41
43
  One generated row (JSON):
@@ -0,0 +1,169 @@
1
+ """Read schema text (JSON or YAML) into plain dicts, lists and strings.
2
+
3
+ Every value keeps the text the user wrote. `1.10` stays "1.10", `0x1F` stays
4
+ "0x1F" and `2026-10-26T10:00:00Z` stays as typed, instead of being converted to
5
+ a number or datetime and printed back differently by Python and Ruby. The only
6
+ conversions:
7
+
8
+ - unquoted yes/no/on/off/true/false (in the case forms below) become
9
+ True/False, and an empty value, ~ or null becomes None;
10
+ - JSON integers are written in plain decimal form.
11
+
12
+ The Ruby implementation (Lattice::SchemaText) follows the same rules, and
13
+ tests/fixtures/scalar_text.* pins the result for both.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from typing import Any
20
+
21
+ import yaml
22
+ from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode
23
+
24
+ from lattice.model import normalize_scalar
25
+
26
+ TRUE_WORDS = frozenset({"yes", "Yes", "YES", "true", "True", "TRUE", "on", "On", "ON"})
27
+ FALSE_WORDS = frozenset({"no", "No", "NO", "false", "False", "FALSE", "off", "Off", "OFF"})
28
+ NULL_WORDS = frozenset({"", "~", "null", "Null", "NULL"})
29
+
30
+ YAML_TAG_PREFIX = "tag:yaml.org,2002:"
31
+ STR_TAG = YAML_TAG_PREFIX + "str"
32
+ MERGE_TAG = YAML_TAG_PREFIX + "merge"
33
+ SCALAR_TAGS = frozenset(
34
+ YAML_TAG_PREFIX + name for name in ("str", "int", "float", "bool", "null", "timestamp", "binary", "merge", "value")
35
+ )
36
+ SEQUENCE_TAG = YAML_TAG_PREFIX + "seq"
37
+ MAPPING_TAG = YAML_TAG_PREFIX + "map"
38
+
39
+
40
+ class SchemaSyntaxError(ValueError):
41
+ """Raised when schema text uses YAML or JSON the schema format doesn't accept."""
42
+
43
+
44
+ class _Loader(yaml.SafeLoader):
45
+ """Marks scalars tagged with the bare `!` as strings, as the YAML spec (and
46
+ Ruby's Psych) do; PyYAML would otherwise resolve `! true` like `true`."""
47
+
48
+ def compose_scalar_node(self, anchor: Any) -> ScalarNode:
49
+ explicit_string = self.peek_event().tag == "!"
50
+ node = super().compose_scalar_node(anchor)
51
+ if explicit_string:
52
+ node.tag = STR_TAG
53
+ return node
54
+
55
+
56
+ def load_json(text: str) -> Any:
57
+ return json.loads(
58
+ text,
59
+ parse_float=str,
60
+ parse_int=lambda digits: str(int(digits)),
61
+ parse_constant=_reject_constant,
62
+ object_pairs_hook=_unique_object,
63
+ )
64
+
65
+
66
+ def load_yaml(text: str) -> Any:
67
+ # compose (not load) keeps each scalar's text; yaml.compose raises on more
68
+ # than one document.
69
+ root = yaml.compose(text, Loader=_Loader)
70
+ if root is None:
71
+ return None
72
+ return _yaml_value(root, set())
73
+
74
+
75
+ def _reject_constant(name: str) -> Any:
76
+ raise SchemaSyntaxError(f"`{name}` is not valid JSON")
77
+
78
+
79
+ def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
80
+ result: dict[str, Any] = {}
81
+ for key, value in pairs:
82
+ if key in result:
83
+ raise SchemaSyntaxError(f"duplicate key `{key}`")
84
+ result[key] = value
85
+ return result
86
+
87
+
88
+ def _yaml_value(node: Node, building: set[int]) -> Any:
89
+ # yaml.compose resolves aliases to the anchored node itself, so a node met
90
+ # again while it is still being built is a recursive alias.
91
+ if id(node) in building:
92
+ raise SchemaSyntaxError("recursive alias")
93
+ building.add(id(node))
94
+ try:
95
+ if isinstance(node, ScalarNode):
96
+ return _yaml_scalar(node)
97
+ if isinstance(node, SequenceNode):
98
+ _check_tag(node.tag, {SEQUENCE_TAG})
99
+ return [_yaml_value(child, building) for child in node.value]
100
+ if isinstance(node, MappingNode):
101
+ return _yaml_mapping(node, building)
102
+ raise SchemaSyntaxError(f"unsupported YAML node `{type(node).__name__}`")
103
+ finally:
104
+ building.discard(id(node))
105
+
106
+
107
+ def _yaml_scalar(node: ScalarNode) -> Any:
108
+ _check_tag(node.tag, SCALAR_TAGS)
109
+ if node.style is not None or node.tag == STR_TAG:
110
+ return node.value
111
+ if node.value in TRUE_WORDS:
112
+ return True
113
+ if node.value in FALSE_WORDS:
114
+ return False
115
+ if node.value in NULL_WORDS:
116
+ return None
117
+ return node.value
118
+
119
+
120
+ def _yaml_mapping(node: MappingNode, building: set[int]) -> dict[Any, Any]:
121
+ """Keys from `<<` merges come first, earlier sources winning; the mapping's
122
+ own keys then override them. A key written twice is an error."""
123
+ _check_tag(node.tag, {MAPPING_TAG})
124
+ merged: dict[Any, Any] = {}
125
+ own: dict[Any, Any] = {}
126
+ seen_merge = False
127
+ for key_node, value_node in node.value:
128
+ key = _yaml_value(key_node, building)
129
+ if isinstance(key, (dict, list)):
130
+ raise SchemaSyntaxError("mapping keys must be scalars")
131
+
132
+ if _merge_key(key_node):
133
+ if seen_merge:
134
+ raise SchemaSyntaxError("duplicate key `<<`")
135
+ seen_merge = True
136
+ for source in _merge_sources(_yaml_value(value_node, building)):
137
+ for merged_key, merged_value in source.items():
138
+ if merged_key not in merged:
139
+ merged[merged_key] = merged_value
140
+ continue
141
+
142
+ if key in own:
143
+ raise SchemaSyntaxError(f"duplicate key `{normalize_scalar(key)}`")
144
+ own[key] = _yaml_value(value_node, building)
145
+
146
+ result = dict(merged)
147
+ result.update(own)
148
+ return result
149
+
150
+
151
+ def _merge_key(key_node: Node) -> bool:
152
+ return (
153
+ isinstance(key_node, ScalarNode)
154
+ and key_node.style is None
155
+ and key_node.value == "<<"
156
+ and key_node.tag == MERGE_TAG
157
+ )
158
+
159
+
160
+ def _merge_sources(value: Any) -> list[dict[Any, Any]]:
161
+ sources = value if isinstance(value, list) else [value]
162
+ if not all(isinstance(source, dict) for source in sources):
163
+ raise SchemaSyntaxError("merge key `<<` needs a mapping or a list of mappings")
164
+ return sources
165
+
166
+
167
+ def _check_tag(tag: str, allowed: set[str] | frozenset[str]) -> None:
168
+ if tag not in allowed:
169
+ raise SchemaSyntaxError(f"unsupported YAML tag `{tag}`")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lattice-cli
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Deterministic combinatorial coverage for agent workflows and constrained variant surfaces.
5
5
  Author-email: Tyler Klose <tylerklose@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -143,6 +143,41 @@ the invalid rule needs multiple antecedents.
143
143
 
144
144
  `conditional` parameters are first-class schema features. The parser expands them into concrete parameter domains and the constraint engine enforces `N/A` semantics automatically.
145
145
 
146
+ A schema with two constraints:
147
+
148
+ ```yaml
149
+ model_name: checkout
150
+ strength: 2
151
+ parameters:
152
+ payment_method: [credit_card, paypal, apple_pay]
153
+ region: [US, UK, DE]
154
+ browser:
155
+ values: [chrome, safari, firefox]
156
+ weights: [0.6, 0.3, 0.1]
157
+ constraints:
158
+ - type: invalid_pair
159
+ if: { parameter: payment_method, value: apple_pay }
160
+ then_not: { parameter: browser, value: firefox }
161
+ - type: conditional
162
+ parameter: card_type
163
+ values: [visa, mastercard]
164
+ parent: payment_method
165
+ applies_when: [credit_card]
166
+ ```
167
+
168
+ Every constraint needs a `type` key. Run `lattice agent instructions` for an example of each type.
169
+
170
+ ### Values keep their text
171
+
172
+ Every value in a schema (the model name, parameter names and values, and constraint references) is used exactly as written. In YAML, unquoted `1.10`, `0x1F`, `.inf` or `2026-10-26T10:00:00Z` stay as typed rather than being turned into numbers or dates. The exceptions:
173
+
174
+ - Unquoted `yes`, `no`, `on`, `off`, `true` and `false` (lowercase, Capitalized or UPPERCASE) become `true` / `false`, and an empty value, `~` or `null` becomes `null`. Quote them (`"yes"`) to keep the word.
175
+ - JSON integers are written in plain decimal form (`-0` becomes `0`).
176
+
177
+ `strength` must be a whole number, and `weights` decimal numbers such as `0.7` or `1e-3`. Anchors, aliases and `<<` merge keys work; a key written twice, more than one YAML document, or a tag other than YAML's standard ones is an error.
178
+
179
+ Changed in 0.3.0: earlier versions converted unquoted numbers and dates, so `0x1F` became `31`, `1.10` became `1.1`, and some values (`.inf`, timestamps) came out differently in the Ruby and Python implementations.
180
+
146
181
  ## Solver Notes
147
182
 
148
183
  The current engine is deterministic and self-contained. If `ortools` is installed via the `solver` extra, Lattice can use CP-SAT feasibility checks for partial assignments; otherwise it falls back to the built-in backtracking solver.
@@ -10,6 +10,7 @@ src/lattice/formatters.py
10
10
  src/lattice/ipog.py
11
11
  src/lattice/model.py
12
12
  src/lattice/parser.py
13
+ src/lattice/schema_text.py
13
14
  src/lattice/resources/claude/lattice-workflow/SKILL.md
14
15
  src/lattice/resources/claude/lattice-workflow/references/modeling-rules.md
15
16
  src/lattice/resources/claude/lattice-workflow/references/worked-examples.md
@@ -28,11 +29,14 @@ src/lattice_cli.egg-info/requires.txt
28
29
  src/lattice_cli.egg-info/top_level.txt
29
30
  tests/test_agent_bootstrap_matrix.py
30
31
  tests/test_cli.py
32
+ tests/test_constraint_type_errors.py
31
33
  tests/test_constraints.py
34
+ tests/test_docs_examples.py
32
35
  tests/test_examples.py
33
36
  tests/test_ipog.py
34
37
  tests/test_model.py
35
38
  tests/test_parser.py
36
39
  tests/test_schema_guidance_matrix.py
40
+ tests/test_schema_text.py
37
41
  tests/test_self_hosting_matrix.py
38
42
  tests/test_yaml_format.py
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import unittest
5
+ from pathlib import Path
6
+ import sys
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+ sys.path.insert(0, str(ROOT / "src"))
10
+
11
+ from lattice.parser import ValidationError, load_model
12
+
13
+ FIXTURES = ROOT / "tests" / "fixtures"
14
+ # Shared with the Ruby implementation (test/fixtures/invalid and
15
+ # test/fixtures/golden/errors there), which must print the same messages.
16
+ CASES = ["constraint_missing_type", "constraint_type_shorthand", "constraint_type_not_string", "unknown_constraint_type"]
17
+
18
+
19
+ class ConstraintTypeErrorTests(unittest.TestCase):
20
+ def test_messages_match_goldens(self) -> None:
21
+ for name in CASES:
22
+ with self.subTest(name=name):
23
+ golden = json.loads((FIXTURES / "golden_errors" / f"{name}.json").read_text())
24
+ with self.assertRaises(ValidationError) as raised:
25
+ load_model(str(FIXTURES / "invalid" / f"{name}.json"))
26
+ self.assertEqual(golden["errors"], raised.exception.errors)
27
+
28
+
29
+ if __name__ == "__main__":
30
+ unittest.main()
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import unittest
5
+ from pathlib import Path
6
+ import sys
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+ sys.path.insert(0, str(ROOT / "src"))
10
+
11
+ from lattice.agent import GENERIC_AGENT_INSTRUCTIONS
12
+ from lattice.constraints import ConstraintEngine
13
+ from lattice.parser import _CONSTRAINT_FIELDS, ValidationError, parse_model_data
14
+ from lattice.schema_text import load_json, load_yaml
15
+
16
+ # Every schema shown to users or agents must be one Lattice accepts. Checks
17
+ # each ```yaml or ```json block that defines `parameters` in the shipped skill
18
+ # files, the README and `lattice agent instructions`.
19
+ FENCED_BLOCK = re.compile(r"^```(yaml|json)\n(.*?)^```", re.M | re.S)
20
+
21
+ SOURCES = {
22
+ str(path.relative_to(ROOT)): path.read_text()
23
+ for path in sorted([*(ROOT / "src/lattice/resources").rglob("*.md"), *(ROOT / ".codex").rglob("*.md")])
24
+ }
25
+ SOURCES["README.md"] = (ROOT / "README.md").read_text()
26
+ SOURCES["lattice agent instructions"] = GENERIC_AGENT_INSTRUCTIONS
27
+
28
+
29
+ def schema_blocks():
30
+ for name, text in SOURCES.items():
31
+ for index, match in enumerate(FENCED_BLOCK.finditer(text), start=1):
32
+ if "parameters" in match.group(2):
33
+ yield name, index, match.group(1), match.group(2)
34
+
35
+
36
+ class DocsExamplesTests(unittest.TestCase):
37
+ def test_finds_a_schema_in_every_skill_readme_and_the_agent_instructions(self) -> None:
38
+ found = {name for name, _index, _language, _body in schema_blocks()}
39
+ expected = {name for name in SOURCES if name.endswith("SKILL.md")} | {"README.md", "lattice agent instructions"}
40
+ self.assertEqual(set(), expected - found)
41
+
42
+ def test_agent_instructions_show_every_constraint_type(self) -> None:
43
+ for constraint_type in _CONSTRAINT_FIELDS:
44
+ self.assertIn(f"- type: {constraint_type}\n", GENERIC_AGENT_INSTRUCTIONS)
45
+
46
+ def test_every_schema_example_is_valid(self) -> None:
47
+ for name, index, language, body in schema_blocks():
48
+ with self.subTest(source=name, block=index):
49
+ data = load_json(body) if language == "json" else load_yaml(body)
50
+ try:
51
+ model = parse_model_data(data)
52
+ except ValidationError as error:
53
+ self.fail(f"{name} block {index} is not a valid schema: {' '.join(error.errors)}")
54
+ self.assertTrue(ConstraintEngine(model).can_complete({}), f"{name} block {index} has no valid rows")
55
+
56
+
57
+ if __name__ == "__main__":
58
+ unittest.main()
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import sys
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ FIXTURES = ROOT / "tests" / "fixtures"
11
+ sys.path.insert(0, str(ROOT / "src"))
12
+
13
+ from lattice.formatters import format_json
14
+ from lattice.ipog import generate_covering_array
15
+ from lattice.parser import ValidationError, load_model, parse_model_data
16
+ from lattice.schema_text import SchemaSyntaxError, load_json, load_yaml
17
+
18
+
19
+ def values(model):
20
+ return {parameter.name: list(parameter.values) for parameter in model.parameters}
21
+
22
+
23
+ class SchemaTextTests(unittest.TestCase):
24
+ """Schema values keep the text the user wrote (lattice.schema_text).
25
+
26
+ The Ruby gem runs the same cases in test/schema_text_test.rb, and
27
+ tests/fixtures/scalar_text.* is shared between the two.
28
+ """
29
+
30
+ def test_yaml_fixture_keeps_value_text(self) -> None:
31
+ model = load_model(str(FIXTURES / "scalar_text.yaml"))
32
+ self.assertEqual(
33
+ values(model),
34
+ {
35
+ "numbers": ["1.10", "0x1F", "012", "1_000", "1e3", "1.0e+20", ".inf", "-.inf", ".nan", "1:30", "+12", "-0"],
36
+ "times": ["2026-10-26", "2026-10-26 10:00:00", "2026-10-26T10:00:00Z", "2026-10-26T10:00:00+02:00"],
37
+ "words": ["true", "false", "null", "yEs", "nO", "y", "yes"],
38
+ "tagged": ["=", "<<", "yes", "07", "1.5"],
39
+ "1.10": ["x", "y"],
40
+ "true": ["p", "q"],
41
+ "weighted": ["a", "b"],
42
+ "merged": ["c", "d"],
43
+ },
44
+ )
45
+ self.assertEqual(model.parameter_lookup["merged"].weights, (0.7, 3.0))
46
+
47
+ def test_yaml_fixture_output_matches_golden(self) -> None:
48
+ model = load_model(str(FIXTURES / "scalar_text.yaml"))
49
+ result = generate_covering_array(model, strength=model.strength, seed=42, pool_size=50)
50
+ golden = (FIXTURES / "scalar_text.golden.json").read_text(encoding="utf-8")
51
+ self.assertEqual(format_json(model, result, seed=42) + "\n", golden)
52
+
53
+ def test_json_fixture_keeps_number_text(self) -> None:
54
+ model = load_model(str(FIXTURES / "scalar_text.json"))
55
+ self.assertEqual(
56
+ values(model),
57
+ {
58
+ "numbers": ["1.10", "1e20", "1E5", "0", "100000000000000000000.0", "12", "0.1", "-2.50"],
59
+ "literals": ["true", "false", "null", "true!"],
60
+ "weighted": ["a", "b"],
61
+ },
62
+ )
63
+ self.assertEqual(model.parameter_lookup["weighted"].weights, (0.25, 2.0))
64
+
65
+ def test_yaml_words(self) -> None:
66
+ words = "yes Yes YES true True TRUE on On ON no No NO false False FALSE off Off OFF ~ null Null NULL yEs nULL y n".split()
67
+ expected = [True] * 9 + [False] * 9 + [None] * 4 + ["yEs", "nULL", "y", "n"]
68
+ self.assertEqual(load_yaml("[" + ", ".join(words) + "]"), expected)
69
+ self.assertEqual(load_yaml("a:\nb: ''\n"), {"a": None, "b": ""})
70
+ self.assertEqual(load_yaml("[\"yes\", 'null', !!str on]"), ["yes", "null", "on"])
71
+ self.assertEqual(load_yaml("[! true, ! x]"), ["true", "x"])
72
+ self.assertEqual(load_yaml("[!!bool yes, !!bool maybe]"), [True, "maybe"])
73
+
74
+ def test_yaml_block_and_folded_scalars_keep_text(self) -> None:
75
+ self.assertEqual(load_yaml("a: |\n yes\nb: >\n 1.10\n"), {"a": "yes\n", "b": "1.10\n"})
76
+
77
+ def test_yaml_empty_document(self) -> None:
78
+ self.assertIsNone(load_yaml(""))
79
+ self.assertIsNone(load_yaml("# only a comment\n"))
80
+
81
+ def test_yaml_aliases_and_merges(self) -> None:
82
+ data = load_yaml(
83
+ "base: &base {x: 1, y: 2}\n"
84
+ "other: &other {y: 3, z: 4}\n"
85
+ "list: &list [a, b]\n"
86
+ "copy: *list\n"
87
+ "one: {<<: *base, x: 9}\n"
88
+ "many: {<<: [*base, *other], w: 0}\n"
89
+ )
90
+ self.assertEqual(data["copy"], ["a", "b"])
91
+ self.assertEqual(data["one"], {"x": "9", "y": "2"})
92
+ self.assertEqual(data["many"], {"x": "1", "y": "2", "z": "4", "w": "0"})
93
+ self.assertEqual(list(data["many"]), ["x", "y", "z", "w"])
94
+
95
+ def test_yaml_merge_key_must_be_plain_untagged_or_merge_tagged(self) -> None:
96
+ self.assertEqual(load_yaml('{"<<": {x: 1}}'), {"<<": {"x": "1"}})
97
+ self.assertEqual(load_yaml("{!!str <<: {x: 1}}"), {"<<": {"x": "1"}})
98
+ self.assertEqual(load_yaml("{!!merge <<: {x: 1}}"), {"x": "1"})
99
+
100
+ def test_yaml_alias_as_key(self) -> None:
101
+ self.assertEqual(load_yaml("k: &m x\nmap: {*m : 1}\n"), {"k": "x", "map": {"x": "1"}})
102
+
103
+ def test_yaml_errors(self) -> None:
104
+ cases = {
105
+ "a: 1\na: 2\n": "duplicate key `a`",
106
+ "yes: 1\nYES: 2\n": "duplicate key `true`",
107
+ "~: 1\nnull: 2\n": "duplicate key `null`",
108
+ "a: !foo x\n": "unsupported YAML tag `!foo`",
109
+ "a: !!set {x}\n": "unsupported YAML tag `tag:yaml.org,2002:set`",
110
+ "a: !!omap [x: 1]\n": "unsupported YAML tag `tag:yaml.org,2002:omap`",
111
+ "? [a]\n: b\n": "mapping keys must be scalars",
112
+ "? {a: 1}\n: b\n": "mapping keys must be scalars",
113
+ "a: {<<: [{x: 1}, y]}\n": "merge key `<<` needs a mapping or a list of mappings",
114
+ "a: {<<: [[{x: 1}]]}\n": "merge key `<<` needs a mapping or a list of mappings",
115
+ "a: {<<: [x]}\n": "merge key `<<` needs a mapping or a list of mappings",
116
+ "a: {<<: {x: 1}, <<: {y: 2}}\n": "duplicate key `<<`",
117
+ "a: &r [*r]\n": "recursive alias",
118
+ }
119
+ for text, message in cases.items():
120
+ with self.subTest(text=text):
121
+ with self.assertRaises(SchemaSyntaxError) as raised:
122
+ load_yaml(text)
123
+ self.assertEqual(str(raised.exception), message)
124
+ # PyYAML itself rejects these (Ruby reports them as SchemaText errors).
125
+ for text in ("a: 1\n---\nb: 2\n", "a: *nope\n"):
126
+ with self.subTest(text=text):
127
+ with self.assertRaises(Exception):
128
+ load_yaml(text)
129
+
130
+ def test_json_errors(self) -> None:
131
+ for text in ('{"a": 1, "a": 2}', "[NaN]", "[Infinity]", "[-Infinity]"):
132
+ with self.subTest(text=text):
133
+ with self.assertRaises(ValueError):
134
+ load_json(text)
135
+
136
+ def test_json_nesting_and_literals(self) -> None:
137
+ self.assertEqual(
138
+ load_json('{"a": [{"b": 1.5}, 7, true, null, "s"]}'),
139
+ {"a": [{"b": "1.5"}, "7", True, None, "s"]},
140
+ )
141
+
142
+ def weight_errors(self, weights):
143
+ try:
144
+ parse_model_data({"parameters": {"a": {"values": ["x", "y"], "weights": weights}}, "strength": 1})
145
+ except ValidationError as error:
146
+ return error.errors
147
+ return None
148
+
149
+ def test_weight_text(self) -> None:
150
+ self.assertIsNone(self.weight_errors(["0.7", "3"]))
151
+ self.assertIsNone(self.weight_errors(["+1.5e-1", "007"]))
152
+ self.assertIsNone(self.weight_errors([0.5, 2]))
153
+ numeric = ["`weights` for parameter `a` must be numeric."]
154
+ for weights in (
155
+ ["0x10", "1"], ["1_0", "1"], [".5", "1"], ["1.", "1"], ["inf", "1"], ["1e400", "1"],
156
+ [True, "1"], [None, "1"], [math.inf, 1], [math.nan, 1], ["\u0661", "1"],
157
+ ):
158
+ with self.subTest(weights=weights):
159
+ self.assertEqual(self.weight_errors(weights), numeric)
160
+ self.assertEqual(self.weight_errors(["0", "1"]), ["`weights` for parameter `a` must be greater than zero."])
161
+ self.assertEqual(self.weight_errors(["-1", "1"]), ["`weights` for parameter `a` must be greater than zero."])
162
+
163
+ def strength_result(self, strength):
164
+ try:
165
+ return parse_model_data(
166
+ {"parameters": {"a": ["x", "y"], "b": ["x", "y"], "c": ["x", "y"]}, "strength": strength}
167
+ ).strength
168
+ except ValidationError as error:
169
+ return error.errors
170
+
171
+ def test_strength_text(self) -> None:
172
+ self.assertEqual(self.strength_result("3"), 3)
173
+ self.assertEqual(self.strength_result("+2"), 2)
174
+ self.assertEqual(self.strength_result("01"), 1)
175
+ self.assertEqual(self.strength_result(2), 2)
176
+ self.assertEqual(self.strength_result(2.9), 2)
177
+ self.assertEqual(self.strength_result(None), 2)
178
+ for strength in ("2.0", "0x2", "1_0", "two", True, math.inf, [2]):
179
+ with self.subTest(strength=strength):
180
+ self.assertEqual(self.strength_result(strength), ["`strength` must be an integer."])
181
+
182
+
183
+ if __name__ == "__main__":
184
+ unittest.main()
File without changes
File without changes