lattice-cli 0.1.2__tar.gz → 0.3.0__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 (42) hide show
  1. {lattice_cli-0.1.2/src/lattice_cli.egg-info → lattice_cli-0.3.0}/PKG-INFO +14 -2
  2. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/README.md +13 -1
  3. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/pyproject.toml +1 -1
  4. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/__init__.py +1 -1
  5. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/cli.py +1 -1
  6. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/formatters.py +70 -0
  7. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/parser.py +41 -14
  8. lattice_cli-0.3.0/src/lattice/schema_text.py +169 -0
  9. {lattice_cli-0.1.2 → lattice_cli-0.3.0/src/lattice_cli.egg-info}/PKG-INFO +14 -2
  10. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice_cli.egg-info/SOURCES.txt +4 -1
  11. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/tests/test_cli.py +1 -1
  12. lattice_cli-0.3.0/tests/test_schema_text.py +184 -0
  13. lattice_cli-0.3.0/tests/test_yaml_format.py +130 -0
  14. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/LICENSE +0 -0
  15. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/setup.cfg +0 -0
  16. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/__main__.py +0 -0
  17. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/agent.py +0 -0
  18. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/constraints.py +0 -0
  19. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/ipog.py +0 -0
  20. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/model.py +0 -0
  21. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/claude/lattice-workflow/SKILL.md +0 -0
  22. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/claude/lattice-workflow/references/modeling-rules.md +0 -0
  23. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/claude/lattice-workflow/references/worked-examples.md +0 -0
  24. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/codex/lattice-workflow/SKILL.md +0 -0
  25. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/codex/lattice-workflow/agents/openai.yaml +0 -0
  26. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/codex/lattice-workflow/references/modeling-rules.md +0 -0
  27. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/codex/lattice-workflow/references/worked-examples.md +0 -0
  28. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/generic/lattice-workflow/SKILL.md +0 -0
  29. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/generic/lattice-workflow/references/modeling-rules.md +0 -0
  30. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/generic/lattice-workflow/references/worked-examples.md +0 -0
  31. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice_cli.egg-info/dependency_links.txt +0 -0
  32. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice_cli.egg-info/entry_points.txt +0 -0
  33. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice_cli.egg-info/requires.txt +0 -0
  34. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice_cli.egg-info/top_level.txt +0 -0
  35. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/tests/test_agent_bootstrap_matrix.py +0 -0
  36. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/tests/test_constraints.py +0 -0
  37. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/tests/test_examples.py +0 -0
  38. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/tests/test_ipog.py +0 -0
  39. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/tests/test_model.py +0 -0
  40. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/tests/test_parser.py +0 -0
  41. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/tests/test_schema_guidance_matrix.py +0 -0
  42. {lattice_cli-0.1.2 → lattice_cli-0.3.0}/tests/test_self_hosting_matrix.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lattice-cli
3
- Version: 0.1.2
3
+ Version: 0.3.0
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
@@ -105,11 +105,12 @@ Supported output formats:
105
105
  - `table`
106
106
  - `csv`
107
107
  - `summary`
108
+ - `yaml` (same content as `json`, for files people edit by hand)
108
109
 
109
110
  `generate` defaults to JSON because the primary consumer is usually a coding harness. File-based schemas are supported, but the schema file is transport, not the product surface.
110
111
 
111
112
  For long higher-strength runs, use `--progress` to stream coverage progress to
112
- stderr while keeping stdout clean for JSON, CSV, table, or summary output.
113
+ stderr while keeping stdout clean for JSON, YAML, CSV, table, or summary output.
113
114
  `--progress-every N` controls how many generated scenarios pass between
114
115
  updates.
115
116
 
@@ -142,6 +143,17 @@ the invalid rule needs multiple antecedents.
142
143
 
143
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.
144
145
 
146
+ ### Values keep their text
147
+
148
+ 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:
149
+
150
+ - 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.
151
+ - JSON integers are written in plain decimal form (`-0` becomes `0`).
152
+
153
+ `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.
154
+
155
+ 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.
156
+
145
157
  ## Solver Notes
146
158
 
147
159
  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.
@@ -77,11 +77,12 @@ Supported output formats:
77
77
  - `table`
78
78
  - `csv`
79
79
  - `summary`
80
+ - `yaml` (same content as `json`, for files people edit by hand)
80
81
 
81
82
  `generate` defaults to JSON because the primary consumer is usually a coding harness. File-based schemas are supported, but the schema file is transport, not the product surface.
82
83
 
83
84
  For long higher-strength runs, use `--progress` to stream coverage progress to
84
- stderr while keeping stdout clean for JSON, CSV, table, or summary output.
85
+ stderr while keeping stdout clean for JSON, YAML, CSV, table, or summary output.
85
86
  `--progress-every N` controls how many generated scenarios pass between
86
87
  updates.
87
88
 
@@ -114,6 +115,17 @@ the invalid rule needs multiple antecedents.
114
115
 
115
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.
116
117
 
118
+ ### Values keep their text
119
+
120
+ 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:
121
+
122
+ - 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.
123
+ - JSON integers are written in plain decimal form (`-0` becomes `0`).
124
+
125
+ `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.
126
+
127
+ 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.
128
+
117
129
  ## Solver Notes
118
130
 
119
131
  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.1.2"
7
+ version = "0.3.0"
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.1.2"
5
+ __version__ = "0.3.0"
@@ -92,7 +92,7 @@ def _build_parser() -> argparse.ArgumentParser:
92
92
  )
93
93
  generate.add_argument("model", nargs="?", help="Path to a JSON or YAML schema. Reads stdin when omitted.")
94
94
  generate.add_argument("--strength", type=int, help="Override the schema strength.")
95
- generate.add_argument("--format", default="json", choices=("table", "json", "csv", "summary"))
95
+ generate.add_argument("--format", default="json", choices=("table", "json", "csv", "summary", "yaml"))
96
96
  generate.add_argument("--seed", type=int, default=42)
97
97
  generate.add_argument("--pool-size", type=int, default=50)
98
98
  generate.add_argument(
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import csv
4
4
  import io
5
5
  import json
6
+ import re
6
7
 
7
8
  from lattice.ipog import GenerationResult
8
9
  from lattice.model import Model
@@ -17,6 +18,8 @@ def render_output(model: Model, result: GenerationResult, *, output_format: str,
17
18
  return format_csv(model, result)
18
19
  if output_format == "summary":
19
20
  return format_summary(model, result)
21
+ if output_format == "yaml":
22
+ return format_yaml(model, result, seed=seed)
20
23
  raise ValueError(f"Unknown format `{output_format}`.")
21
24
 
22
25
 
@@ -85,6 +88,73 @@ def format_json(model: Model, result: GenerationResult, *, seed: int) -> str:
85
88
  return json.dumps(payload, indent=2)
86
89
 
87
90
 
91
+ def format_yaml(model: Model, result: GenerationResult, *, seed: int | None) -> str:
92
+ """Same content as format_json, written by hand in one fixed style.
93
+
94
+ PyYAML and Ruby's Psych quote and wrap differently, so the Python and Ruby
95
+ implementations write YAML themselves to emit identical bytes. Every string
96
+ is double-quoted, so no value can be read back as a boolean, null, number
97
+ or date.
98
+ """
99
+ coverage = f"{result.coverage_curve[-1].cumulative_pct:.1f}%" if result.coverage_curve else "0.0%"
100
+ lines = [
101
+ "meta:",
102
+ f" model_name: {_yaml_string(model.model_name)}",
103
+ f" strength: {model.strength}",
104
+ f" test_count: {len(result.rows)}",
105
+ f" exhaustive_count: {model.exhaustive_count}",
106
+ f" reduction: {_yaml_string(_reduction_label(model.exhaustive_count, len(result.rows)))}",
107
+ f" coverage: {_yaml_string(coverage)}",
108
+ f" seed: {'null' if seed is None else seed}",
109
+ ]
110
+
111
+ if not result.coverage_curve:
112
+ lines.append("coverage_curve: []")
113
+ else:
114
+ lines.append("coverage_curve:")
115
+ for point in result.coverage_curve:
116
+ lines.append(f" - scenario: {point.scenario}")
117
+ lines.append(f" cumulative_pct: {json.dumps(point.cumulative_pct)}")
118
+
119
+ rows = _named_rows(model, result)
120
+ if not rows:
121
+ lines.append("scenarios: []")
122
+ else:
123
+ lines.append("scenarios:")
124
+ for index, row in enumerate(rows):
125
+ lines.append(f" - id: {index + 1}")
126
+ lines.append(" values:")
127
+ for name in model.parameter_names:
128
+ lines.append(f" {_yaml_string(name)}: {_yaml_string(row[name])}")
129
+ return "\n".join(lines)
130
+
131
+
132
+ _YAML_ESCAPES = {
133
+ '"': '\\"',
134
+ "\\": "\\\\",
135
+ "\b": "\\b",
136
+ "\t": "\\t",
137
+ "\n": "\\n",
138
+ "\f": "\\f",
139
+ "\r": "\\r",
140
+ }
141
+
142
+ # Characters escaped inside a double-quoted YAML string: the quote and
143
+ # backslash, C0 controls, DEL, C1 controls (U+0085 would otherwise fold to a
144
+ # space), the Unicode line and paragraph separators, the byte-order mark, and
145
+ # the two noncharacters YAML cannot hold. Everything else is written as UTF-8,
146
+ # so accented letters and emoji stay readable.
147
+ _YAML_ESCAPED_CHARS = re.compile(r'["\\\u0000-\u001f\u007f-\u009f\u2028\u2029\ufeff\ufffe\uffff]')
148
+
149
+
150
+ def _yaml_string(value: str) -> str:
151
+ def escape(match: re.Match[str]) -> str:
152
+ char = match.group(0)
153
+ return _YAML_ESCAPES.get(char) or f"\\u{ord(char):04x}"
154
+
155
+ return '"' + _YAML_ESCAPED_CHARS.sub(escape, value) + '"'
156
+
157
+
88
158
  def format_csv(model: Model, result: GenerationResult) -> str:
89
159
  buffer = io.StringIO()
90
160
  writer = csv.writer(buffer)
@@ -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:
@@ -376,26 +376,53 @@ def _parse_weights(
376
376
  return None
377
377
 
378
378
  parsed: list[float] = []
379
- for weight in raw_weights:
380
- if not isinstance(weight, (int, float)):
379
+ for raw_weight in raw_weights:
380
+ weight = _parse_number(raw_weight)
381
+ if weight is None:
381
382
  errors.append(f"`weights` for parameter `{parameter_name}` must be numeric.")
382
383
  return None
383
384
  if weight <= 0:
384
385
  errors.append(f"`weights` for parameter `{parameter_name}` must be greater than zero.")
385
386
  return None
386
- parsed.append(float(weight))
387
+ parsed.append(weight)
387
388
  return tuple(parsed)
388
389
 
389
390
 
391
+ _DECIMAL_TEXT = re.compile(r"[-+]?[0-9]+(\.[0-9]+)?([eE][-+]?[0-9]+)?")
392
+ _INTEGER_TEXT = re.compile(r"[-+]?[0-9]+")
393
+
394
+
395
+ def _parse_number(raw: Any) -> float | None:
396
+ """A finite number, from a Python number or decimal text such as "0.7" or
397
+ "1e-3" (schema files keep numbers as text). Booleans are not numbers."""
398
+ if isinstance(raw, bool):
399
+ return None
400
+ if isinstance(raw, (int, float)):
401
+ number = float(raw)
402
+ elif isinstance(raw, str) and _DECIMAL_TEXT.fullmatch(raw):
403
+ number = float(raw)
404
+ else:
405
+ return None
406
+ return number if math.isfinite(number) else None
407
+
408
+
390
409
  def _parse_strength(raw_strength: Any, parameter_count: int, errors: list[str]) -> int:
410
+ strength: int | None
391
411
  if raw_strength is None:
392
412
  strength = 2
413
+ elif isinstance(raw_strength, bool):
414
+ strength = None
415
+ elif isinstance(raw_strength, int):
416
+ strength = raw_strength
417
+ elif isinstance(raw_strength, float):
418
+ strength = int(raw_strength) if math.isfinite(raw_strength) else None
419
+ elif isinstance(raw_strength, str) and _INTEGER_TEXT.fullmatch(raw_strength):
420
+ strength = int(raw_strength, 10)
393
421
  else:
394
- try:
395
- strength = int(raw_strength)
396
- except (TypeError, ValueError):
397
- errors.append("`strength` must be an integer.")
398
- return 2
422
+ strength = None
423
+ if strength is None:
424
+ errors.append("`strength` must be an integer.")
425
+ return 2
399
426
 
400
427
  if strength < 1:
401
428
  errors.append("`strength` must be at least 1.")
@@ -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.1.2
3
+ Version: 0.3.0
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
@@ -105,11 +105,12 @@ Supported output formats:
105
105
  - `table`
106
106
  - `csv`
107
107
  - `summary`
108
+ - `yaml` (same content as `json`, for files people edit by hand)
108
109
 
109
110
  `generate` defaults to JSON because the primary consumer is usually a coding harness. File-based schemas are supported, but the schema file is transport, not the product surface.
110
111
 
111
112
  For long higher-strength runs, use `--progress` to stream coverage progress to
112
- stderr while keeping stdout clean for JSON, CSV, table, or summary output.
113
+ stderr while keeping stdout clean for JSON, YAML, CSV, table, or summary output.
113
114
  `--progress-every N` controls how many generated scenarios pass between
114
115
  updates.
115
116
 
@@ -142,6 +143,17 @@ the invalid rule needs multiple antecedents.
142
143
 
143
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.
144
145
 
146
+ ### Values keep their text
147
+
148
+ 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:
149
+
150
+ - 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.
151
+ - JSON integers are written in plain decimal form (`-0` becomes `0`).
152
+
153
+ `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.
154
+
155
+ 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.
156
+
145
157
  ## Solver Notes
146
158
 
147
159
  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
@@ -34,4 +35,6 @@ tests/test_ipog.py
34
35
  tests/test_model.py
35
36
  tests/test_parser.py
36
37
  tests/test_schema_guidance_matrix.py
37
- tests/test_self_hosting_matrix.py
38
+ tests/test_schema_text.py
39
+ tests/test_self_hosting_matrix.py
40
+ tests/test_yaml_format.py
@@ -168,7 +168,7 @@ class CliTests(unittest.TestCase):
168
168
  self.assertEqual(parsed["surface"], "generic")
169
169
  self.assertTrue((target / "SKILL.md").is_file())
170
170
  self.assertTrue((target / "references" / "modeling-rules.md").is_file())
171
- self.assertIn("Do not strip true constraints", (target / "SKILL.md").read_text())
171
+ self.assertIn("do not strip them and mark invalid rows downstream", (target / "SKILL.md").read_text())
172
172
  self.assertIn(
173
173
  "mark them invalid downstream",
174
174
  (target / "references" / "modeling-rules.md").read_text(),
@@ -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()
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+ ROOT = Path(__file__).resolve().parents[1]
13
+ FIXTURES = ROOT / "tests" / "fixtures"
14
+ sys.path.insert(0, str(ROOT / "src"))
15
+
16
+ from lattice.formatters import _yaml_string, format_json, format_yaml, render_output
17
+ from lattice.ipog import GenerationResult, generate_covering_array
18
+ from lattice.parser import load_model
19
+
20
+
21
+ def uescape(hex_digits: str) -> str:
22
+ # Built in pieces so the source stays ASCII.
23
+ return '"' + "\\" + "u" + hex_digits + '"'
24
+
25
+
26
+ def quoted(*code_points: int) -> str:
27
+ return '"' + "".join(chr(point) for point in code_points) + '"'
28
+
29
+
30
+ class YamlFormatTests(unittest.TestCase):
31
+ """`--format yaml`: the same content as `--format json`, written by hand.
32
+
33
+ tests/fixtures/yaml_edges.golden.yaml pins the exact bytes for
34
+ tests/fixtures/yaml_edges.json; the Ruby gem (latticerb) tests against
35
+ the same pair.
36
+ """
37
+
38
+ def generate(self, fixture: str):
39
+ model = load_model(str(FIXTURES / fixture))
40
+ return model, generate_covering_array(model, strength=model.strength, seed=42, pool_size=50)
41
+
42
+ def test_edge_case_fixture_matches_golden_bytes(self) -> None:
43
+ model, result = self.generate("yaml_edges.json")
44
+ golden = (FIXTURES / "yaml_edges.golden.yaml").read_text(encoding="utf-8")
45
+ self.assertEqual(format_yaml(model, result, seed=42) + "\n", golden)
46
+
47
+ def test_yaml_reads_back_as_the_json_payload(self) -> None:
48
+ for fixture in ("simple.json", "constrained.json", "skillit.json", "yaml_edges.json"):
49
+ with self.subTest(fixture=fixture):
50
+ model, result = self.generate(fixture)
51
+ self.assertEqual(
52
+ yaml.safe_load(format_yaml(model, result, seed=42)),
53
+ json.loads(format_json(model, result, seed=42)),
54
+ )
55
+
56
+ def test_empty_result_writes_empty_lists_and_null_seed(self) -> None:
57
+ model = load_model(str(FIXTURES / "simple.json"))
58
+ result = GenerationResult(rows=(), coverage_curve=(), total_valid_tuples=0)
59
+ expected = "\n".join(
60
+ [
61
+ "meta:",
62
+ ' model_name: "simple"',
63
+ " strength: 2",
64
+ " test_count: 0",
65
+ " exhaustive_count: 8",
66
+ ' reduction: "inf"',
67
+ ' coverage: "0.0%"',
68
+ " seed: null",
69
+ "coverage_curve: []",
70
+ "scenarios: []",
71
+ ]
72
+ )
73
+ self.assertEqual(render_output(model, result, output_format="yaml", seed=None), expected)
74
+
75
+ def test_yaml_string_escapes(self) -> None:
76
+ backslash = "\\"
77
+ cases = {
78
+ "plain": '"plain"',
79
+ "": '""',
80
+ "yes": '"yes"',
81
+ "~": '"~"',
82
+ 'say "hi"': '"say ' + backslash + '"hi' + backslash + '""',
83
+ "back" + backslash + "slash": '"back' + backslash * 2 + 'slash"',
84
+ chr(0x08): '"' + backslash + 'b"',
85
+ chr(0x09): '"' + backslash + 't"',
86
+ chr(0x0A): '"' + backslash + 'n"',
87
+ chr(0x0C): '"' + backslash + 'f"',
88
+ chr(0x0D): '"' + backslash + 'r"',
89
+ chr(0x00): uescape("0000"),
90
+ chr(0x07): uescape("0007"),
91
+ chr(0x0B): uescape("000b"),
92
+ chr(0x1F): uescape("001f"),
93
+ chr(0x7E): '"~"',
94
+ chr(0x7F): uescape("007f"),
95
+ chr(0x80): uescape("0080"),
96
+ chr(0x85): uescape("0085"),
97
+ chr(0x9F): uescape("009f"),
98
+ chr(0xA0): quoted(0xA0),
99
+ "caf" + chr(0xE9): quoted(0x63, 0x61, 0x66, 0xE9),
100
+ chr(0x2027): quoted(0x2027),
101
+ chr(0x2028): uescape("2028"),
102
+ chr(0x2029): uescape("2029"),
103
+ chr(0x202A): quoted(0x202A),
104
+ chr(0xFEFF): uescape("feff"),
105
+ chr(0xFFFD): quoted(0xFFFD),
106
+ chr(0xFFFE): uescape("fffe"),
107
+ chr(0xFFFF): uescape("ffff"),
108
+ chr(0x1F600): quoted(0x1F600),
109
+ }
110
+ for value, expected in cases.items():
111
+ with self.subTest(value=ascii(value)):
112
+ self.assertEqual(_yaml_string(value), expected)
113
+
114
+ def test_cli_generate_format_yaml(self) -> None:
115
+ env = dict(os.environ)
116
+ env["PYTHONPATH"] = str(ROOT / "src")
117
+ env["PYTHONIOENCODING"] = "utf-8"
118
+ result = subprocess.run(
119
+ [sys.executable, "-m", "lattice", "generate", "--format", "yaml", str(FIXTURES / "yaml_edges.json")],
120
+ cwd=ROOT,
121
+ env=env,
122
+ capture_output=True,
123
+ check=False,
124
+ )
125
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
126
+ self.assertEqual(result.stdout, (FIXTURES / "yaml_edges.golden.yaml").read_bytes())
127
+
128
+
129
+ if __name__ == "__main__":
130
+ unittest.main()
File without changes
File without changes