latticerb 0.1.0 → 0.3.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f5250f7a9782b3db18aa0002eda4b3194f1092d0a832e4b764ce1c49cbb8f202
4
- data.tar.gz: 7f2a83e342e5498728f3fac15d20e6706859a10a7b2697b666ccb0e67e72bccf
3
+ metadata.gz: e1cd02dabe1821baf68283ad31fb90aa688bd1642943fa2f5d914cad759389fe
4
+ data.tar.gz: 4c1f1118737aa6fe2a2917db077fd6badf3f03fc169c110bf5c5255a1b8376aa
5
5
  SHA512:
6
- metadata.gz: 51b3c0f8c486a628f9e4274d2a6f66b2f79a7eb75c519e892c760cd3c65cdebedb005cd845189505f29bcac63ac716c6f3d0d249f38fb4c3a5a8a96d4543a381
7
- data.tar.gz: 781caca48f95fc5ef3d8562c717ebe4dd2b987630a6ca2d5a886f51a8db0c06abdfeb4f19713531133c3e27a9149861821ff093385ad8b2f649d9ef5b118aa74
6
+ metadata.gz: '087a54e0c2896d3a71ffb4eb8737dbf3ad2a0e04ed36682d8fa232cee7b7a131f706cdf5456277c2c6b3eef59446c263dd8ff4277879345160b386c64b06f8d0'
7
+ data.tar.gz: 0fceafc2667a8042a38c7dc292b42d185b7305cf57cbf2a7d21bab7d1915723d0fd5fb33c27a444a349b9bff148357a7313f55542af393a69cf69402551426f3
data/README.md CHANGED
@@ -54,7 +54,7 @@ run = Lattice.generate(
54
54
 
55
55
  run.scenarios # array of { name => value } hashes
56
56
  run.coverage_curve # cumulative coverage per scenario
57
- run.to_table # human-readable table (also: to_json_output, to_csv, to_summary)
57
+ run.to_table # human-readable table (also: to_json_output, to_yaml_output, to_csv, to_summary)
58
58
 
59
59
  Lattice.scenarios(file: "...") # shortcut straight to the hashes
60
60
  Lattice.validate!(schema) # raises Lattice::ValidationError / Lattice::ConstraintError
@@ -70,6 +70,7 @@ The gem installs a `lattice` executable mirroring the Python CLI:
70
70
  cat schema.yaml | lattice validate
71
71
  cat schema.yaml | lattice generate # JSON by default
72
72
  lattice generate schema.yaml --format table
73
+ lattice generate schema.yaml --format yaml # same content as JSON, for hand-edited files
73
74
  lattice generate schema.yaml --strength 3 --seed 7
74
75
  lattice agent instructions # coding-agent integration guidance
75
76
  ```
@@ -98,11 +99,22 @@ constraints:
98
99
 
99
100
  Constraint types: `invalid_pair`, `higher_order`, `forward_dep`, `bidirectional`, `conditional`, `forced`. Run `lattice agent instructions` for the full reference.
100
101
 
102
+ ### Values keep their text
103
+
104
+ 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:
105
+
106
+ - 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.
107
+ - JSON integers are written in plain decimal form (`-0` becomes `0`).
108
+
109
+ `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.
110
+
111
+ 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.
112
+
101
113
  ## Parity with the Python implementation
102
114
 
103
115
  The generator seeds an MT19937 RNG that reproduces CPython's `random.Random` bit-for-bit, so both implementations walk the same candidate pools and pick the same rows. `test/python_parity_test.rb` shells out to the Python implementation (checkout via `LATTICE_PYTHON_SRC`, or an installed `lattice-cli`) and asserts byte-equal output across formats, seeds, strengths, and stopping rules; it skips when no Python implementation is available.
104
116
 
105
- YAML is parsed with Psych here and PyYAML in Python — both YAML 1.1 (libyaml lineage), so common scalars including unquoted `yes`/`no`/`on`/`off` booleans resolve identically. Exotic YAML 1.1 corners (e.g. sexagesimal numbers) may differ between the two parsers; quote ambiguous scalars or use JSON if in doubt.
117
+ Schemas are read with Psych here and PyYAML in Python, but both implementations build values from the parsed text with the same rules (see "Values keep their text"), so a schema means the same thing in either. `test/fixtures/scalar_text.*` pins those rules for both.
106
118
 
107
119
  ## Development
108
120
 
data/lib/lattice/cli.rb CHANGED
@@ -65,7 +65,7 @@ module Lattice
65
65
  }
66
66
  parser = OptionParser.new do |opts|
67
67
  opts.on("--strength STRENGTH", Integer)
68
- opts.on("--format FORMAT", %w[table json csv summary])
68
+ opts.on("--format FORMAT", %w[table json csv summary yaml])
69
69
  opts.on("--seed SEED", Integer)
70
70
  opts.on("--pool-size SIZE", Integer) { |value| options[:pool_size] = value }
71
71
  opts.on("--max-rows ROWS", Integer) { |value| options[:max_rows] = value }
@@ -7,8 +7,9 @@ require_relative "ipog"
7
7
  require_relative "model"
8
8
 
9
9
  module Lattice
10
- # Output rendering. Table, JSON, CSV, and summary formats are byte-identical
11
- # to the Python implementation's output for the same model and result.
10
+ # Output rendering. Table, JSON, CSV, summary, and YAML formats are
11
+ # byte-identical to the Python implementation's output for the same model
12
+ # and result.
12
13
  module Formatters
13
14
  extend self
14
15
 
@@ -18,6 +19,7 @@ module Lattice
18
19
  when "json" then format_json(model, result, seed:)
19
20
  when "csv" then format_csv(model, result)
20
21
  when "summary" then format_summary(model, result)
22
+ when "yaml" then format_yaml(model, result, seed:)
21
23
  else
22
24
  raise ArgumentError, "Unknown format `#{output_format}`."
23
25
  end
@@ -77,16 +79,81 @@ module Lattice
77
79
  }
78
80
  end
79
81
  }
80
- JSON.pretty_generate(payload, ascii_only: true)
82
+ # Python's json.dumps also escapes DEL, which Ruby's ascii_only leaves raw.
83
+ JSON.pretty_generate(payload, ascii_only: true).gsub("\u007f", "\\u007f")
81
84
  end
82
85
 
83
- def format_csv(model, result)
84
- CSV.generate(row_sep: "\r\n") do |csv|
85
- csv << model.parameter_names
86
- named_rows(model, result).each do |row|
87
- csv << model.parameter_names.map { |name| row[name] }
86
+ # Same content as format_json, written by hand in one fixed style so Ruby
87
+ # and Python emit identical bytes (Psych and PyYAML each quote and wrap
88
+ # differently). Every string is double-quoted, so no value can be read
89
+ # back as a boolean, null, number or date.
90
+ def format_yaml(model, result, seed:)
91
+ last_point = result.coverage_curve.last
92
+ lines = [
93
+ "meta:",
94
+ " model_name: #{yaml_string(model.model_name)}",
95
+ " strength: #{model.strength}",
96
+ " test_count: #{result.rows.length}",
97
+ " exhaustive_count: #{model.exhaustive_count}",
98
+ " reduction: #{yaml_string(reduction_label(model.exhaustive_count, result.rows.length))}",
99
+ " coverage: #{yaml_string(last_point ? format('%.1f%%', last_point.cumulative_pct) : '0.0%')}",
100
+ " seed: #{seed.nil? ? 'null' : seed}"
101
+ ]
102
+
103
+ if result.coverage_curve.empty?
104
+ lines << "coverage_curve: []"
105
+ else
106
+ lines << "coverage_curve:"
107
+ result.coverage_curve.each do |point|
108
+ lines << " - scenario: #{point.scenario}"
109
+ lines << " cumulative_pct: #{JSON.generate(point.cumulative_pct)}"
110
+ end
111
+ end
112
+
113
+ rows = named_rows(model, result)
114
+ if rows.empty?
115
+ lines << "scenarios: []"
116
+ else
117
+ lines << "scenarios:"
118
+ rows.each_with_index do |row, index|
119
+ lines << " - id: #{index + 1}"
120
+ lines << " values:"
121
+ model.parameter_names.each do |name|
122
+ lines << " #{yaml_string(name)}: #{yaml_string(row[name])}"
123
+ end
88
124
  end
89
- end.strip
125
+ end
126
+ lines.join("\n")
127
+ end
128
+
129
+ YAML_ESCAPES = {
130
+ "\"" => "\\\"", "\\" => "\\\\", "\b" => "\\b", "\t" => "\\t", "\n" => "\\n", "\f" => "\\f", "\r" => "\\r"
131
+ }.freeze
132
+
133
+ # Characters escaped inside a double-quoted YAML string: the quote and
134
+ # backslash, C0 controls, DEL, C1 controls (U+0085 would otherwise fold to
135
+ # a space), the Unicode line and paragraph separators, the byte-order mark,
136
+ # and the two noncharacters YAML cannot hold. Everything else is written as
137
+ # UTF-8, so accented letters and emoji stay readable.
138
+ YAML_ESCAPED_CHARS = /["\\\u0000-\u001f\u007f-\u009f\u2028\u2029\ufeff\ufffe\uffff]/
139
+
140
+ def yaml_string(value)
141
+ escaped = value.gsub(YAML_ESCAPED_CHARS) do |char|
142
+ YAML_ESCAPES.fetch(char) { format("\\u%04x", char.ord) }
143
+ end
144
+ "\"#{escaped}\""
145
+ end
146
+
147
+ # Matches Python's csv.writer: empty fields are written bare, except a row
148
+ # made of a single empty field, which is written as "" so it isn't a blank
149
+ # line.
150
+ def format_csv(model, result)
151
+ records = [model.parameter_names] + named_rows(model, result).map do |row|
152
+ model.parameter_names.map { |name| row[name] }
153
+ end
154
+ records.map do |record|
155
+ record == [""] ? "\"\"\r\n" : CSV.generate_line(record, row_sep: "\r\n", quote_empty: false)
156
+ end.join.strip
90
157
  end
91
158
 
92
159
  def headline(model, result)
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "yaml"
5
-
6
3
  require_relative "model"
4
+ require_relative "schema_text"
7
5
 
8
6
  module Lattice
9
7
  class ModelIOError < StandardError; end
@@ -78,7 +76,7 @@ module Lattice
78
76
 
79
77
  errors = []
80
78
  loaders.each do |loader|
81
- return loader == :json ? JSON.parse(text) : safe_load_yaml(text)
79
+ return loader == :json ? SchemaText.load_json(text) : SchemaText.load_yaml(text)
82
80
  rescue StandardError => e
83
81
  errors << e.message
84
82
  end
@@ -86,10 +84,6 @@ module Lattice
86
84
  raise ModelIOError, "Unable to parse model from `#{source_name}`: #{errors.join('; ')}"
87
85
  end
88
86
 
89
- def safe_load_yaml(text)
90
- YAML.safe_load(text, permitted_classes: [Date, Time], aliases: true)
91
- end
92
-
93
87
  def parse_parameters(raw_parameters, errors)
94
88
  parameters = {}
95
89
  if raw_parameters.nil?
@@ -351,8 +345,9 @@ module Lattice
351
345
  end
352
346
 
353
347
  parsed = []
354
- raw_weights.each do |weight|
355
- unless weight.is_a?(Numeric)
348
+ raw_weights.each do |raw_weight|
349
+ weight = parse_number(raw_weight)
350
+ if weight.nil?
356
351
  errors << "`weights` for parameter `#{parameter_name}` must be numeric."
357
352
  return nil
358
353
  end
@@ -360,24 +355,37 @@ module Lattice
360
355
  errors << "`weights` for parameter `#{parameter_name}` must be greater than zero."
361
356
  return nil
362
357
  end
363
- parsed << weight.to_f
358
+ parsed << weight
364
359
  end
365
360
  parsed
366
361
  end
367
362
 
363
+ DECIMAL_TEXT = /\A[-+]?[0-9]+(\.[0-9]+)?([eE][-+]?[0-9]+)?\z/
364
+ INTEGER_TEXT = /\A[-+]?[0-9]+\z/
365
+
366
+ # A finite number, from a Ruby number or decimal text such as "0.7" or
367
+ # "1e-3" (schema files keep numbers as text). Booleans are not numbers.
368
+ def parse_number(raw)
369
+ number =
370
+ case raw
371
+ when Integer, Float then raw.to_f
372
+ when String then DECIMAL_TEXT.match?(raw) ? Float(raw) : nil
373
+ end
374
+ number&.finite? ? number : nil
375
+ end
376
+
368
377
  def parse_strength(raw_strength, parameter_count, errors)
369
378
  strength =
370
379
  case raw_strength
371
380
  when nil then 2
372
- when Numeric then raw_strength.to_i
373
- else
374
- begin
375
- Integer(raw_strength)
376
- rescue TypeError, ArgumentError
377
- errors << "`strength` must be an integer."
378
- return 2
379
- end
381
+ when Integer then raw_strength
382
+ when Float then raw_strength.finite? ? raw_strength.to_i : nil
383
+ when String then INTEGER_TEXT.match?(raw_strength) ? Integer(raw_strength, 10) : nil
380
384
  end
385
+ if strength.nil?
386
+ errors << "`strength` must be an integer."
387
+ return 2
388
+ end
381
389
 
382
390
  errors << "`strength` must be at least 1." if strength < 1
383
391
  errors << "`strength` cannot exceed the number of parameters." if parameter_count.positive? && strength > parameter_count
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "psych"
5
+
6
+ module Lattice
7
+ # Reads schema text (JSON or YAML) into plain Hashes, Arrays and Strings,
8
+ # keeping every value as the text the user wrote. `1.10` stays "1.10",
9
+ # `0x1F` stays "0x1F" and `2026-10-26T10:00:00Z` stays as typed, instead of
10
+ # being converted to a number or time and printed back differently by Ruby
11
+ # and Python. The only conversions:
12
+ #
13
+ # - unquoted yes/no/on/off/true/false (in the case forms below) become
14
+ # true/false, and an empty value, ~ or null becomes nil;
15
+ # - JSON integers are written in plain decimal form.
16
+ #
17
+ # The Python implementation (lattice.schema_text) follows the same rules, and
18
+ # test/fixtures/scalar_text.* pins the result for both.
19
+ module SchemaText
20
+ extend self
21
+
22
+ class SyntaxError < StandardError; end
23
+
24
+ TRUE_WORDS = %w[yes Yes YES true True TRUE on On ON].freeze
25
+ FALSE_WORDS = %w[no No NO false False FALSE off Off OFF].freeze
26
+ NULL_WORDS = ["", "~", "null", "Null", "NULL"].freeze
27
+
28
+ YAML_TAG_PREFIX = "tag:yaml.org,2002:"
29
+ STR_TAGS = ["!", "#{YAML_TAG_PREFIX}str"].freeze
30
+ SCALAR_TAGS = %w[str int float bool null timestamp binary merge value].map { |name| "#{YAML_TAG_PREFIX}#{name}" }.freeze
31
+ SEQUENCE_TAGS = [nil, "!", "#{YAML_TAG_PREFIX}seq"].freeze
32
+ MAPPING_TAGS = [nil, "!", "#{YAML_TAG_PREFIX}map"].freeze
33
+
34
+ # Hands JSON floats back as their source text.
35
+ class RawNumber
36
+ def self.new(text) = text
37
+ end
38
+
39
+ # Rejects a key written twice. json 2.x calls []= on a custom object_class;
40
+ # json 3.x checks duplicates itself when allow_duplicate_key is false.
41
+ class UniqueHash < Hash
42
+ def []=(key, value)
43
+ raise JSON::ParserError, "duplicate key `#{key}`" if key?(key)
44
+
45
+ super
46
+ end
47
+ end
48
+
49
+ def load_json(text)
50
+ json_strings(
51
+ JSON.parse(text, decimal_class: RawNumber, object_class: UniqueHash, allow_duplicate_key: false)
52
+ )
53
+ end
54
+
55
+ def load_yaml(text)
56
+ documents = Psych.parse_stream(text).children
57
+ raise SyntaxError, "expected a single YAML document, found #{documents.length}" if documents.length > 1
58
+ return nil if documents.empty?
59
+
60
+ yaml_value(documents.first.root, {})
61
+ end
62
+
63
+ private
64
+
65
+ def json_strings(value)
66
+ case value
67
+ when Hash then value.to_h { |key, child| [key, json_strings(child)] }
68
+ when Array then value.map { |child| json_strings(child) }
69
+ when Integer then value.to_s
70
+ else value
71
+ end
72
+ end
73
+
74
+ # `anchors` maps each anchor to its value once built; an anchor still
75
+ # being built (nil value) means an alias points back into itself.
76
+ def yaml_value(node, anchors)
77
+ return yaml_alias(node, anchors) if node.is_a?(Psych::Nodes::Alias)
78
+
79
+ anchors[node.anchor] = nil if node.anchor
80
+ value =
81
+ case node
82
+ when Psych::Nodes::Scalar then yaml_scalar(node)
83
+ when Psych::Nodes::Sequence
84
+ check_tag(node.tag, SEQUENCE_TAGS)
85
+ node.children.map { |child| yaml_value(child, anchors) }
86
+ when Psych::Nodes::Mapping then yaml_mapping(node, anchors)
87
+ end
88
+ anchors[node.anchor] = [value] if node.anchor
89
+ value
90
+ end
91
+
92
+ def yaml_alias(node, anchors)
93
+ built = anchors.fetch(node.anchor) { raise SyntaxError, "unknown alias `*#{node.anchor}`" }
94
+ raise SyntaxError, "recursive alias" if built.nil?
95
+
96
+ built.first
97
+ end
98
+
99
+ def yaml_scalar(node)
100
+ check_tag(node.tag, [nil, "!"] + SCALAR_TAGS)
101
+ return node.value unless node.style == Psych::Nodes::Scalar::PLAIN && !STR_TAGS.include?(node.tag)
102
+ return true if TRUE_WORDS.include?(node.value)
103
+ return false if FALSE_WORDS.include?(node.value)
104
+ return nil if NULL_WORDS.include?(node.value)
105
+
106
+ node.value
107
+ end
108
+
109
+ # Keys from `<<` merges come first, earlier sources winning; the
110
+ # mapping's own keys then override them. A key written twice is an error.
111
+ def yaml_mapping(node, anchors)
112
+ check_tag(node.tag, MAPPING_TAGS)
113
+ merged = {}
114
+ own = {}
115
+ seen_merge = false
116
+ node.children.each_slice(2) do |key_node, value_node|
117
+ key = yaml_value(key_node, anchors)
118
+ raise SyntaxError, "mapping keys must be scalars" if key.is_a?(Enumerable)
119
+
120
+ if merge_key?(key_node)
121
+ raise SyntaxError, "duplicate key `<<`" if seen_merge
122
+
123
+ seen_merge = true
124
+ merge_sources(yaml_value(value_node, anchors)).each do |source|
125
+ source.each { |merged_key, merged_value| merged[merged_key] = merged_value unless merged.key?(merged_key) }
126
+ end
127
+ next
128
+ end
129
+
130
+ raise SyntaxError, "duplicate key `#{Lattice.normalize_scalar(key)}`" if own.key?(key)
131
+
132
+ own[key] = yaml_value(value_node, anchors)
133
+ end
134
+ merged.merge(own)
135
+ end
136
+
137
+ def merge_key?(key_node)
138
+ key_node.is_a?(Psych::Nodes::Scalar) && key_node.style == Psych::Nodes::Scalar::PLAIN && key_node.value == "<<" &&
139
+ [nil, "#{YAML_TAG_PREFIX}merge"].include?(key_node.tag)
140
+ end
141
+
142
+ def merge_sources(value)
143
+ sources = [value].flatten(1)
144
+ raise SyntaxError, "merge key `<<` needs a mapping or a list of mappings" unless sources.all?(Hash)
145
+
146
+ sources
147
+ end
148
+
149
+ def check_tag(tag, allowed)
150
+ raise SyntaxError, "unsupported YAML tag `#{tag}`" unless allowed.include?(tag)
151
+ end
152
+ end
153
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lattice
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/lattice.rb CHANGED
@@ -32,6 +32,7 @@ module Lattice
32
32
  def to_json_output = Formatters.render_output(model, result, output_format: "json", seed:)
33
33
  def to_csv = Formatters.render_output(model, result, output_format: "csv", seed:)
34
34
  def to_summary = Formatters.render_output(model, result, output_format: "summary", seed:)
35
+ def to_yaml_output = Formatters.render_output(model, result, output_format: "yaml", seed:)
35
36
  end
36
37
 
37
38
  class << self
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: latticerb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tyler Klose
@@ -56,6 +56,7 @@ files:
56
56
  - lib/lattice/resources/generic/lattice-workflow/SKILL.md
57
57
  - lib/lattice/resources/generic/lattice-workflow/references/modeling-rules.md
58
58
  - lib/lattice/resources/generic/lattice-workflow/references/worked-examples.md
59
+ - lib/lattice/schema_text.rb
59
60
  - lib/lattice/version.rb
60
61
  - lib/latticerb.rb
61
62
  homepage: https://github.com/tylerklose/lattice-ruby