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.
- {lattice_cli-0.1.2/src/lattice_cli.egg-info → lattice_cli-0.3.0}/PKG-INFO +14 -2
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/README.md +13 -1
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/pyproject.toml +1 -1
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/__init__.py +1 -1
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/cli.py +1 -1
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/formatters.py +70 -0
- {lattice_cli-0.1.2 → 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.1.2 → lattice_cli-0.3.0/src/lattice_cli.egg-info}/PKG-INFO +14 -2
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice_cli.egg-info/SOURCES.txt +4 -1
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/tests/test_cli.py +1 -1
- lattice_cli-0.3.0/tests/test_schema_text.py +184 -0
- lattice_cli-0.3.0/tests/test_yaml_format.py +130 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/LICENSE +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/setup.cfg +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/__main__.py +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/agent.py +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/constraints.py +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/ipog.py +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/model.py +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/claude/lattice-workflow/SKILL.md +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/claude/lattice-workflow/references/modeling-rules.md +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/claude/lattice-workflow/references/worked-examples.md +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/codex/lattice-workflow/SKILL.md +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/codex/lattice-workflow/agents/openai.yaml +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/codex/lattice-workflow/references/modeling-rules.md +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/codex/lattice-workflow/references/worked-examples.md +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/generic/lattice-workflow/SKILL.md +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/generic/lattice-workflow/references/modeling-rules.md +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice/resources/generic/lattice-workflow/references/worked-examples.md +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice_cli.egg-info/dependency_links.txt +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice_cli.egg-info/entry_points.txt +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice_cli.egg-info/requires.txt +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/src/lattice_cli.egg-info/top_level.txt +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/tests/test_agent_bootstrap_matrix.py +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/tests/test_constraints.py +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/tests/test_examples.py +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/tests/test_ipog.py +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/tests/test_model.py +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/tests/test_parser.py +0 -0
- {lattice_cli-0.1.2 → lattice_cli-0.3.0}/tests/test_schema_guidance_matrix.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -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
|
|
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
|
|
@@ -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/
|
|
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("
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lattice_cli-0.1.2 → 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.1.2 → 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.1.2 → 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
|