lattice-cli 0.2.0__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.
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/PKG-INFO +12 -1
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/README.md +11 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/pyproject.toml +1 -1
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/__init__.py +1 -1
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/parser.py +41 -14
- lattice_cli-0.3.0/src/lattice/schema_text.py +169 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice_cli.egg-info/PKG-INFO +12 -1
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice_cli.egg-info/SOURCES.txt +2 -0
- lattice_cli-0.3.0/tests/test_schema_text.py +184 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/LICENSE +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/setup.cfg +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/__main__.py +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/agent.py +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/cli.py +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/constraints.py +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/formatters.py +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/ipog.py +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/model.py +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/resources/claude/lattice-workflow/SKILL.md +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/resources/claude/lattice-workflow/references/modeling-rules.md +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/resources/claude/lattice-workflow/references/worked-examples.md +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/resources/codex/lattice-workflow/SKILL.md +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/resources/codex/lattice-workflow/agents/openai.yaml +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/resources/codex/lattice-workflow/references/modeling-rules.md +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/resources/codex/lattice-workflow/references/worked-examples.md +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/resources/generic/lattice-workflow/SKILL.md +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/resources/generic/lattice-workflow/references/modeling-rules.md +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/resources/generic/lattice-workflow/references/worked-examples.md +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice_cli.egg-info/dependency_links.txt +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice_cli.egg-info/entry_points.txt +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice_cli.egg-info/requires.txt +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice_cli.egg-info/top_level.txt +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/tests/test_agent_bootstrap_matrix.py +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/tests/test_cli.py +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/tests/test_constraints.py +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/tests/test_examples.py +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/tests/test_ipog.py +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/tests/test_model.py +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/tests/test_parser.py +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/tests/test_schema_guidance_matrix.py +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/tests/test_self_hosting_matrix.py +0 -0
- {lattice_cli-0.2.0 → lattice_cli-0.3.0}/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.
|
|
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
|
|
@@ -143,6 +143,17 @@ 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
|
+
### 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
|
+
|
|
146
157
|
## Solver Notes
|
|
147
158
|
|
|
148
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.
|
|
@@ -115,6 +115,17 @@ 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
|
+
### 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
|
+
|
|
118
129
|
## Solver Notes
|
|
119
130
|
|
|
120
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.
|
|
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"
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
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 = [
|
|
91
|
+
loaders = [load_json]
|
|
92
92
|
elif extension in {".yaml", ".yml"}:
|
|
93
|
-
loaders = [
|
|
93
|
+
loaders = [load_yaml]
|
|
94
94
|
else:
|
|
95
|
-
loaders = [
|
|
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
|
|
380
|
-
|
|
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(
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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.
|
|
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
|
|
@@ -143,6 +143,17 @@ 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
|
+
### 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
|
+
|
|
146
157
|
## Solver Notes
|
|
147
158
|
|
|
148
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,5 +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
|
|
38
|
+
tests/test_schema_text.py
|
|
37
39
|
tests/test_self_hosting_matrix.py
|
|
38
40
|
tests/test_yaml_format.py
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/resources/claude/lattice-workflow/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/resources/codex/lattice-workflow/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lattice_cli-0.2.0 → lattice_cli-0.3.0}/src/lattice/resources/generic/lattice-workflow/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|