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