optivibe-reference 0.2.0__tar.gz → 0.2.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.
Files changed (42) hide show
  1. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/PKG-INFO +2 -3
  2. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/pyproject.toml +3 -4
  3. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/__init__.py +1 -1
  4. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/build_data.py +40 -8
  5. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/catalog_build.py +3 -0
  6. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/glass_build.py +30 -4
  7. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/glass_parse.py +30 -0
  8. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/manual_enrich.py +12 -8
  9. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/probe_operands.py +4 -0
  10. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/probe_tolerances.py +4 -0
  11. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/tolerance_build.py +3 -0
  12. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/tools/find_glass_pair.py +13 -8
  13. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/tools/find_glasses.py +151 -7
  14. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference.egg-info/PKG-INFO +2 -3
  15. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/README.md +0 -0
  16. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/setup.cfg +0 -0
  17. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/_envelope.py +0 -0
  18. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/_paths.py +0 -0
  19. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/_probe_common.py +0 -0
  20. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/build_steps.py +0 -0
  21. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/data/operand_semantics.json +0 -0
  22. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/data/operand_synonyms.json +0 -0
  23. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/data/provenance_grants.txt +0 -0
  24. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/data/tolerance_operand_synonyms.json +0 -0
  25. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/errors.py +0 -0
  26. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/glass_dispersion.py +0 -0
  27. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/glass_math.py +0 -0
  28. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/manual_build.py +0 -0
  29. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/server.py +0 -0
  30. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/tools/__init__.py +0 -0
  31. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/tools/_glass_common.py +0 -0
  32. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/tools/_glass_kind.py +0 -0
  33. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/tools/_query_params.py +0 -0
  34. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/tools/_rag_match.py +0 -0
  35. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/tools/lookup_glass.py +0 -0
  36. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/tools/lookup_operand.py +0 -0
  37. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference/tools/search_reference.py +0 -0
  38. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference.egg-info/SOURCES.txt +0 -0
  39. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference.egg-info/dependency_links.txt +0 -0
  40. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference.egg-info/entry_points.txt +0 -0
  41. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference.egg-info/requires.txt +0 -0
  42. {optivibe_reference-0.2.0 → optivibe_reference-0.2.1}/src/optivibe_reference.egg-info/top_level.txt +0 -0
@@ -1,11 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optivibe-reference
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: OptiVibe reference layer: lookup tables + RAG + intent->ZOS-API grounding
5
- License: Apache-2.0
5
+ License-Expression: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/chuang44-tiff/optivibe-mcp
7
7
  Project-URL: Issues, https://github.com/chuang44-tiff/optivibe-mcp/issues
8
- Classifier: License :: OSI Approved :: Apache Software License
9
8
  Classifier: Programming Language :: Python :: 3.11
10
9
  Requires-Python: >=3.11
11
10
  Description-Content-Type: text/markdown
@@ -20,18 +20,17 @@
20
20
  # licensed install, with no clone. The `probe` extra adds pythonnet for
21
21
  # `optivibe-build-data --probe`; the optivibe-mcp install already carries it.
22
22
  [build-system]
23
- requires = ["setuptools>=61"]
23
+ requires = ["setuptools>=77"]
24
24
  build-backend = "setuptools.build_meta"
25
25
 
26
26
  [project]
27
27
  name = "optivibe-reference"
28
- version = "0.2.0"
28
+ version = "0.2.1"
29
29
  description = "OptiVibe reference layer: lookup tables + RAG + intent->ZOS-API grounding"
30
30
  requires-python = ">=3.11"
31
31
  readme = "README.md"
32
- license = {text = "Apache-2.0"}
32
+ license = "Apache-2.0"
33
33
  classifiers = [
34
- "License :: OSI Approved :: Apache Software License",
35
34
  "Programming Language :: Python :: 3.11",
36
35
  ]
37
36
  dependencies = []
@@ -11,4 +11,4 @@ grounds design intent onto a merit-function operand. The catalog is built from a
11
11
  gitignored, user-built normalized-LF JSON at runtime; the ``.db`` is never committed.
12
12
  """
13
13
 
14
- __version__ = "0.2.0"
14
+ __version__ = "0.2.1"
@@ -69,8 +69,18 @@ def _say(text):
69
69
  print(_ascii(text))
70
70
 
71
71
 
72
+ _NOT_WRITABLE_HINT = (
73
+ " (if the install location is not writable -- the build writes into the "
74
+ "package's data and captures directories, so install optivibe-mcp into a virtual "
75
+ "environment, or use `pip install --user`, then re-run.)"
76
+ )
77
+
78
+
72
79
  def _error_text(exc):
73
- return _ascii("%s: %s" % (type(exc).__name__, exc))
80
+ text = "%s: %s" % (type(exc).__name__, exc)
81
+ if isinstance(exc, PermissionError):
82
+ text += _NOT_WRITABLE_HINT
83
+ return _ascii(text)
74
84
 
75
85
 
76
86
  def _captures_present(names, captures=None):
@@ -91,6 +101,16 @@ def probed_count(capture_path):
91
101
  return count if isinstance(count, int) and not isinstance(count, bool) else None
92
102
 
93
103
 
104
+ def _capture_incomplete(capture_path):
105
+ """True iff the capture reads and says ``ok: false``: a probe that failed after its first
106
+ step writes its capture early (so the count matches the new inventory) but never marks it ok."""
107
+ try:
108
+ with open(capture_path, encoding="utf-8") as fh:
109
+ return json.load(fh).get("ok") is False
110
+ except (OSError, ValueError, AttributeError):
111
+ return False
112
+
113
+
94
114
  def catalog_step_blocker(captures, captures_dir=None):
95
115
  """Why this operand/tolerance catalog cannot be built for the probed install, or None.
96
116
 
@@ -102,8 +122,12 @@ def catalog_step_blocker(captures, captures_dir=None):
102
122
  """
103
123
  captures_dir = _paths.captures_dir() if captures_dir is None else captures_dir
104
124
  curated = int(re.search(r"_(\d+)\.json$", captures[0]).group(1))
105
- probed = probed_count(os.path.join(captures_dir, captures[1]))
125
+ capture = os.path.join(captures_dir, captures[1])
126
+ probed = probed_count(capture)
106
127
  if probed is None or probed == curated:
128
+ if _capture_incomplete(capture):
129
+ return ("the last live probe did not finish (%s is marked incomplete), so this "
130
+ "catalog is not built from it; re-run `optivibe-build-data --probe`." % captures[1])
107
131
  return None
108
132
  kind = "tolerance" if captures[0].startswith("tolerance") else "merit"
109
133
  return ("your OpticStudio exposes %d %s operands; the curated data shipped with "
@@ -112,11 +136,20 @@ def catalog_step_blocker(captures, captures_dir=None):
112
136
  "change that)." % (probed, kind, curated))
113
137
 
114
138
 
115
- def _run_probe(label, probe_main):
116
- """Run one live probe; return 1 iff it FAILED (a missing pythonnet included)."""
139
+ def _run_probe(label, module_name):
140
+ """Run one live probe; return 1 iff it FAILED (a missing pythonnet included).
141
+
142
+ The probe module is imported INSIDE the guard, so a broken import is that probe's
143
+ FAILED report and the other probe still runs.
144
+ """
117
145
  # Lazy: importing the probe modules is cheap and clr-free, but keep them off the
118
146
  # import path of a plain build run.
119
- from ._probe_common import PROBE_EXTRA_HINT, PythonnetMissing
147
+ try:
148
+ from ._probe_common import PROBE_EXTRA_HINT, PythonnetMissing
149
+ probe_main = importlib.import_module("." + module_name, __package__).main
150
+ except Exception as exc: # noqa: BLE001 — report per-step, keep going
151
+ _say("[%s] FAILED - cannot import the probe: %s" % (label, _error_text(exc)))
152
+ return 1
120
153
  try:
121
154
  rc = probe_main()
122
155
  except PythonnetMissing:
@@ -134,12 +167,11 @@ def _run_probe(label, probe_main):
134
167
 
135
168
  def run_probes():
136
169
  """Step 0: the two live probes, merit first. Returns the number that FAILED."""
137
- from . import probe_operands, probe_tolerances
138
170
  failures = 0
139
171
  _say("=== [probe 1/2] merit operand surface (boots a headless OpticStudio) ===")
140
- failures += _run_probe("probe-operands", lambda: probe_operands.main())
172
+ failures += _run_probe("probe-operands", "probe_operands")
141
173
  _say("=== [probe 2/2] tolerance operand surface (boots a headless OpticStudio) ===")
142
- failures += _run_probe("probe-tolerances", lambda: probe_tolerances.main())
174
+ failures += _run_probe("probe-tolerances", "probe_tolerances")
143
175
  return failures
144
176
 
145
177
 
@@ -519,6 +519,9 @@ def build_catalog_json(
519
519
  """
520
520
  inventory = _load_json(inventory_path)
521
521
  probe = _load_json(probe_capture_path)
522
+ _require(probe.get("ok") is not False,
523
+ "the probe capture %s is marked incomplete (ok: false): the last live probe did not "
524
+ "finish; re-run `optivibe-build-data --probe`" % probe_capture_path)
522
525
 
523
526
  synonyms = load_synonyms_rows(synonyms_path) if synonyms_path else {}
524
527
  # The verbatim-local descriptions (gitignored). ``None`` (path absent or not
@@ -25,6 +25,7 @@ CLOSED unless the ``.agf``-derivation ledger row is on file in ``PROVENANCE.md``
25
25
  """
26
26
  import json
27
27
  import os
28
+ import re
28
29
  import sqlite3
29
30
 
30
31
  from . import _paths
@@ -42,7 +43,7 @@ from .glass_dispersion import (
42
43
  )
43
44
  from .glass_parse import find_catalogs, iter_glass_records
44
45
 
45
- SCHEMA_VERSION = 1
46
+ SCHEMA_VERSION = 2
46
47
  BUILDER_VERSION = 1
47
48
 
48
49
  # A computed value whose |nF-nC| denominator is below this floor is a near-flat-
@@ -108,6 +109,11 @@ def in_band(min_wave, max_wave, line):
108
109
  and min_wave <= line <= max_wave)
109
110
 
110
111
 
112
+ # NM status code -> name (the five names; any other code or None reads None).
113
+ STATUS_NAMES = {0: "standard", 1: "preferred", 2: "obsolete", 3: "special", 4: "melt"}
114
+ _BUILD_KEYS = ("tce", "relative_cost", "climate", "stain", "acid", "alkali", "phosphate")
115
+
116
+
111
117
  def _build_row(rec):
112
118
  """Recompute one parsed record into a committed-JSON row dict (§5/§6)."""
113
119
  formula = rec["formula"]
@@ -169,6 +175,9 @@ def _build_row(rec):
169
175
  "nd_valid": nd_valid,
170
176
  "pg_f_valid": pg_f_valid,
171
177
  "provenance": "agf_recompute",
178
+ "status": STATUS_NAMES.get(rec.get("status_code")),
179
+ **{k: rec.get(k) for k in _BUILD_KEYS},
180
+ "it": rec.get("it") or [],
172
181
  }
173
182
 
174
183
 
@@ -289,6 +298,9 @@ def write_glass_catalog_json(out_path, catalog):
289
298
  """
290
299
  os.makedirs(os.path.dirname(out_path), exist_ok=True)
291
300
  text = json.dumps(catalog, indent=2, ensure_ascii=True)
301
+ # each row's ``it`` list on ONE line (it would otherwise take one line per number)
302
+ text = re.sub(r'(?<="it": )\[\n.*?\n \]',
303
+ lambda m: json.dumps(json.loads(m.group(0))), text, flags=re.DOTALL)
292
304
  with open(out_path, "w", encoding="utf-8", newline="\n") as fh:
293
305
  fh.write(text)
294
306
  fh.write("\n")
@@ -321,6 +333,15 @@ CREATE TABLE glass (
321
333
  nd_valid INTEGER NOT NULL,
322
334
  pg_f_valid INTEGER NOT NULL,
323
335
  provenance TEXT,
336
+ status TEXT,
337
+ tce REAL,
338
+ relative_cost REAL,
339
+ climate REAL,
340
+ stain REAL,
341
+ acid REAL,
342
+ alkali REAL,
343
+ phosphate REAL,
344
+ it TEXT,
324
345
  PRIMARY KEY (catalog, name)
325
346
  )
326
347
  """
@@ -354,7 +375,8 @@ def build_glass_db(catalog_json_path, conn):
354
375
  in use — YAGNI).
355
376
 
356
377
  The SQL ``PRIMARY KEY (catalog, name)`` raises ``IntegrityError`` on a
357
- duplicate (the storage-layer dedupe). ``cd`` is stored as a JSON string. The
378
+ duplicate (the storage-layer dedupe). ``cd`` and ``it`` are stored as JSON strings (a schema-1
379
+ JSON has no buildability keys: those columns read NULL). The
358
380
  ``*_valid`` flags are stored as 0/1 INTEGERs. Returns ``conn``.
359
381
  """
360
382
  # One-shot precondition: a pre-existing ``glass`` table means this connection
@@ -374,8 +396,9 @@ def build_glass_db(catalog_json_path, conn):
374
396
  conn.execute(
375
397
  "INSERT INTO glass (catalog, name, formula, formula_name, cd, "
376
398
  "min_wave, max_wave, nd_stored, vd_stored, delta_pg_f, nd, ng, nf, "
377
- "nc, vd, pg_f, nd_valid, pg_f_valid, provenance) "
378
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
399
+ "nc, vd, pg_f, nd_valid, pg_f_valid, provenance, status, tce, "
400
+ "relative_cost, climate, stain, acid, alkali, phosphate, it) "
401
+ "VALUES (" + ", ".join("?" * 28) + ")",
379
402
  (
380
403
  row["catalog"],
381
404
  row["name"],
@@ -396,6 +419,9 @@ def build_glass_db(catalog_json_path, conn):
396
419
  1 if row.get("nd_valid") else 0,
397
420
  1 if row.get("pg_f_valid") else 0,
398
421
  row.get("provenance"),
422
+ row.get("status"),
423
+ *(row.get(k) for k in _BUILD_KEYS),
424
+ json.dumps(row["it"]) if row.get("it") is not None else None,
399
425
  ),
400
426
  )
401
427
  conn.commit()
@@ -19,11 +19,15 @@ glass names are committed).
19
19
  Record grammar (opened by ``NM``):
20
20
  name=NM[1], formula=parse_formula_field(NM[2]), nd_stored=NM[4], vd_stored=NM[5];
21
21
  density=ED[3], dpgf=ED[4]; cd=CD[1:]; min_wave=LD[1], max_wave=LD[2].
22
+ Also (-1 or absent reads None): status_code=NM[7], tce=ED[1] (0 reads None), OD[1..6], IT rows.
22
23
  """
23
24
  import glob
24
25
  import os
25
26
 
26
27
 
28
+ _OD_KEYS = ("relative_cost", "climate", "stain", "acid", "alkali", "phosphate")
29
+
30
+
27
31
  def parse_formula_field(tok):
28
32
  """Parse NM field 3 robustly (handles '1.00000000E+00' sci-notation -> 1).
29
33
 
@@ -54,6 +58,14 @@ def parse_float(tok):
54
58
  return None
55
59
 
56
60
 
61
+ def _field(p, i):
62
+ """Float of split field ``i``, or None when absent, unparseable or the file's unset marker -1."""
63
+ v = parse_float(p[i]) if len(p) > i else None
64
+ if v is None or abs(v + 1.0) < 1e-9:
65
+ return None
66
+ return v
67
+
68
+
57
69
  def _decode_agf(raw):
58
70
  """Decode raw .agf bytes: BOM sniff -> BOM-less UTF-8 attempt -> latin-1.
59
71
 
@@ -105,6 +117,9 @@ def iter_glass_records(path):
105
117
  minw = maxw = None
106
118
  have = False
107
119
  formula_is_sci = False
120
+ status_code = tce = None
121
+ od = {}
122
+ it = []
108
123
 
109
124
  def emit():
110
125
  return {
@@ -112,6 +127,7 @@ def iter_glass_records(path):
112
127
  "density": density, "dpgf": dpgf, "cd": cd,
113
128
  "min_wave": minw, "max_wave": maxw, "catalog": os.path.basename(path),
114
129
  "formula_is_sci": formula_is_sci,
130
+ "status_code": status_code, "tce": tce, "it": it, **od,
115
131
  }
116
132
 
117
133
  with open(path, "rb") as fh:
@@ -131,14 +147,28 @@ def iter_glass_records(path):
131
147
  density = dpgf = None
132
148
  cd = None
133
149
  minw = maxw = None
150
+ status_code = _field(p, 7)
151
+ status_code = int(status_code) if status_code is not None and status_code.is_integer() else None
152
+ tce = None
153
+ od = dict.fromkeys(_OD_KEYS)
154
+ it = []
134
155
  have = True
135
156
  elif ln.startswith("ED ") and have:
136
157
  # ED tce1 tce2 density dPgF ... (dPgF = ED field 4 -> token[4])
137
158
  p = ln.split()
138
159
  density = parse_float(p[3]) if len(p) > 3 else None
139
160
  dpgf = parse_float(p[4]) if len(p) > 4 else None
161
+ tce = _field(p, 1) or None
140
162
  elif ln.startswith("CD ") and have:
141
163
  cd = [parse_float(x) for x in ln.split()[1:]]
164
+ elif ln.startswith("OD ") and have:
165
+ p = ln.split()
166
+ od = {k: _field(p, i) for i, k in enumerate(_OD_KEYS, 1)}
167
+ elif ln.startswith("IT ") and have:
168
+ p = ln.split()
169
+ w, t, d = _field(p, 1), _field(p, 2), _field(p, 3)
170
+ if None not in (w, t, d) and d > 0:
171
+ it.append([w, t, d])
142
172
  elif ln.startswith("LD ") and have:
143
173
  p = ln.split()
144
174
  minw = parse_float(p[1]) if len(p) > 1 else None
@@ -38,8 +38,8 @@ MERIT_INVENTORY_PATH = os.path.join(
38
38
  def load_merit_operand_inventory(path=MERIT_INVENTORY_PATH):
39
39
  """Return the merit operand code set (a ``frozenset``) from the user-built inventory.
40
40
 
41
- A missing file names its PRODUCER: the capture is written by ``scripts/probe_operands.py``
42
- (a live probe).
41
+ A missing file names its PRODUCER: the capture is written by the live probe
42
+ (``optivibe-build-data --probe``; in a clone also ``scripts/probe_operands.py``).
43
43
 
44
44
  Raises loudly (``OSError`` / ``KeyError`` / ``ValueError``) on a missing or
45
45
  malformed file — the caller must never silently fall back to the ungated pairer.
@@ -49,9 +49,11 @@ def load_merit_operand_inventory(path=MERIT_INVENTORY_PATH):
49
49
  inventory = json.load(fh)
50
50
  except FileNotFoundError as exc:
51
51
  raise FileNotFoundError(
52
- f"merit operand inventory {path} is missing -- it is written by "
53
- f"packages/optivibe-reference/scripts/probe_operands.py (a live probe; "
54
- f"operand_inventory_<N>.json under scripts/captures/)") from exc
52
+ f"merit operand inventory {path} is missing -- it is written by the live "
53
+ f"probe: run `optivibe-build-data --probe` (searched captures dir: "
54
+ f"{os.path.dirname(os.path.abspath(path))}). In a clone the same probe is "
55
+ f"packages/optivibe-reference/scripts/probe_operands.py "
56
+ f"(operand_inventory_<N>.json under scripts/captures/)") from exc
55
57
  codes = frozenset(m["code"] for m in inventory["members"])
56
58
  if len(codes) != inventory["total_members"]:
57
59
  raise ValueError(
@@ -75,9 +77,11 @@ def load_tolerance_operand_inventory(path=None):
75
77
  inventory = json.load(fh)
76
78
  except FileNotFoundError as exc:
77
79
  raise FileNotFoundError(
78
- f"tolerance operand inventory {path} is missing -- it is written by "
79
- f"packages/optivibe-reference/scripts/probe_tolerances.py (a live probe; "
80
- f"tolerance_inventory_<N>.json under scripts/captures/)") from exc
80
+ f"tolerance operand inventory {path} is missing -- it is written by the "
81
+ f"live probe: run `optivibe-build-data --probe` (searched captures dir: "
82
+ f"{os.path.dirname(os.path.abspath(path))}). In a clone the same probe is "
83
+ f"packages/optivibe-reference/scripts/probe_tolerances.py "
84
+ f"(tolerance_inventory_<N>.json under scripts/captures/)") from exc
81
85
  codes = frozenset(m["code"] for m in inventory["members"])
82
86
  if len(codes) != inventory["total_members"]:
83
87
  raise ValueError(
@@ -198,6 +198,10 @@ def main():
198
198
  }
199
199
  report["inventory_count"] = n
200
200
  report["inventory_file"] = write_json(f"operand_inventory_{n}.json", inventory)
201
+ # Write the capture NOW (ok False = incomplete) so its inventory_count matches the
202
+ # inventory just written even if P2-P4 or the teardown raises; the final write
203
+ # below overwrites it.
204
+ write_json("probe_operands_capture.json", report)
201
205
 
202
206
  # --- P2: live-label existence ---
203
207
  print("[probe] P2 — live-label existence (TypeName/RowTypeName/AvailableOperandTypes)")
@@ -341,6 +341,10 @@ def main():
341
341
  }
342
342
  report["inventory_count"] = n
343
343
  report["inventory_file"] = write_json(f"tolerance_inventory_{n}.json", inventory)
344
+ # Write the capture NOW (ok False = incomplete) so its inventory_count matches the
345
+ # inventory just written even if P2-P4 or the teardown raises; the final write
346
+ # below overwrites it.
347
+ write_json("probe_tolerances_capture.json", report)
344
348
 
345
349
  # --- P2: live-label existence ---
346
350
  print("[probe] P2 — live-label existence (TypeName/RowTypeName/Comment/...)")
@@ -255,6 +255,9 @@ def build_tolerance_catalog_json(
255
255
  """
256
256
  inventory = _load_json(inventory_path)
257
257
  capture = _load_json(capture_path)
258
+ catalog_build._require(capture.get("ok") is not False,
259
+ "the probe capture %s is marked incomplete (ok: false): the last live probe did not "
260
+ "finish; re-run `optivibe-build-data --probe`" % capture_path)
258
261
 
259
262
  synonyms = (
260
263
  catalog_build.load_synonyms_rows(synonyms_path, tolerance=True)
@@ -184,7 +184,7 @@ def _load_pair_eligible(db_conn, catalogs):
184
184
  where += f" AND LOWER(catalog) IN ({marks})"
185
185
  sql_params.extend(c.lower() for c in catalogs)
186
186
  sql = (
187
- "SELECT catalog, name, vd, pg_f, nd FROM glass "
187
+ "SELECT catalog, name, vd, pg_f, nd, tce FROM glass "
188
188
  f"WHERE {where} ORDER BY pg_f, catalog, name"
189
189
  )
190
190
  return db_conn.execute(sql, sql_params).fetchall()
@@ -226,7 +226,7 @@ def _resolve_anchor(db_conn, anchor, anchor_catalog, catalogs):
226
226
  sql_params.append(anchor_catalog)
227
227
  rows = db_conn.execute(
228
228
  "SELECT catalog, name, vd, pg_f, nd, pg_f_valid, "
229
- "(vd IS NOT NULL), nd_valid FROM glass "
229
+ "(vd IS NOT NULL), nd_valid, tce FROM glass "
230
230
  f"WHERE {where} ORDER BY catalog",
231
231
  sql_params,
232
232
  ).fetchall()
@@ -298,18 +298,22 @@ def _pair_numbers(a, b):
298
298
  }
299
299
 
300
300
 
301
- def _pair_dict(a, b, delta_vd, delta_pg_f, score):
301
+ def _pair_dict(a, b, delta_vd, delta_pg_f, score, anchor_row=None):
302
302
  """Build one result pair dict with the HIGHER-Vd glass as ``a`` for stability."""
303
303
  # Canonical orientation: the higher-Vd (crown-ish) glass first is a stable,
304
304
  # readable convention. a/b are (catalog, name, vd, pg_f, nd) tuples.
305
305
  if a[2] < b[2]:
306
306
  a, b = b, a
307
+ # delta_tce = tce(second) - tce(first), the anchor first when there is one; None if either is unknown.
308
+ first, second = (b, a) if anchor_row and (b[0], b[1]) == anchor_row[:2] else (a, b)
309
+ delta_tce = None if None in (first[5], second[5]) else round(second[5] - first[5], 3)
307
310
  return {
308
311
  "a": {"catalog": a[0], "name": a[1], "vd": a[2], "pg_f": a[3], "nd": a[4]},
309
312
  "b": {"catalog": b[0], "name": b[1], "vd": b[2], "pg_f": b[3], "nd": b[4]},
310
313
  "delta_vd": delta_vd,
311
314
  "delta_pg_f": delta_pg_f,
312
315
  "score": score,
316
+ "delta_tce": delta_tce,
313
317
  **_pair_numbers(a, b),
314
318
  }
315
319
 
@@ -334,14 +338,14 @@ def _emit_pairs(eligible, cluster_ids, max_delta_pg_f, min_delta_vd, anchor_row)
334
338
  # so an emitted (A, B) never re-appears as (B, A).
335
339
  hi = 0
336
340
  for i in range(n):
337
- cat_i, name_i, vd_i, pgf_i, nd_i = eligible[i]
341
+ cat_i, name_i, vd_i, pgf_i, nd_i = eligible[i][:5]
338
342
  cid_i = cluster_ids[i]
339
343
  if hi < i + 1:
340
344
  hi = i + 1
341
345
  while hi < n and (eligible[hi][3] - pgf_i) <= max_delta_pg_f:
342
346
  hi += 1
343
347
  for j in range(i + 1, hi):
344
- cat_j, name_j, vd_j, pgf_j, nd_j = eligible[j]
348
+ cat_j, name_j, vd_j, pgf_j, nd_j = eligible[j][:5]
345
349
  cid_j = cluster_ids[j]
346
350
  # Self-pair exclusion: same physical glass (same cluster) OR the exact
347
351
  # same (catalog, name) row. A same-name-but-DIFFERENT-properties pair
@@ -365,7 +369,7 @@ def _emit_pairs(eligible, cluster_ids, max_delta_pg_f, min_delta_vd, anchor_row)
365
369
  continue
366
370
  score = _score(delta_vd, delta_pg_f)
367
371
  pair = _pair_dict(
368
- eligible[i], eligible[j], delta_vd, delta_pg_f, score
372
+ eligible[i], eligible[j], delta_vd, delta_pg_f, score, anchor_row
369
373
  )
370
374
  # Stash the unordered physical-identity key for the dedup step.
371
375
  pair["_cluster_key"] = frozenset((cid_i, cid_j))
@@ -492,7 +496,7 @@ def find_glass_pair(db_conn, params):
492
496
  "dispersion) - no pairs"
493
497
  ),
494
498
  }
495
- anchor_row = (resolved[0], resolved[1], resolved[2], resolved[3], resolved[4])
499
+ anchor_row = (resolved[0], resolved[1], resolved[2], resolved[3], resolved[4], resolved[8])
496
500
 
497
501
  eligible, left_out_kinds, unclassified = _build_pool(
498
502
  db_conn, catalogs, excluded, anchor_row, max_delta_pg_f, min_delta_vd
@@ -555,7 +559,8 @@ FIND_GLASS_PAIR_SPEC = ToolSpec(
555
559
  "heuristic prefilter, and cut at limit; a smaller max_delta_pg_f brings "
556
560
  "low secondary-colour pairs to the top. Each pair also carries "
557
561
  "secondary_per_focal, power_sum and petzval_per_power for a thin cemented "
558
- "doublet of the pair; read caveat in the result. Gotcha: an ambiguous bare anchor name returns an "
562
+ "doublet of the pair, and delta_tce (thermal expansion of the second glass minus "
563
+ "the first, the anchor first; null when either is unknown); read caveat in the result. Gotcha: an ambiguous bare anchor name returns an "
559
564
  "ambiguous_glass candidate list - qualify the catalog. See find_glasses to "
560
565
  "window-search single glasses. See lookup_glass for one glass's full data."
561
566
  ),
@@ -20,7 +20,7 @@ NEVER-RAISE contract (mirrors ``lookup_glass`` / §7):
20
20
  - a ``min > max`` window is a ``ToolParamError`` (an empty-by-construction window
21
21
  is a caller mistake, not a legitimately-empty result);
22
22
  - AT LEAST ONE window bound is required (a bare ``find_glasses`` with no window
23
- would dump the whole 4783-row catalog — refuse it as a ``ToolParamError``).
23
+ would dump the whole 4782-row catalog — refuse it as a ``ToolParamError``).
24
24
 
25
25
  THE VALIDITY GATE (the cardinal defense surfaced at the window boundary): a
26
26
  computed field (``vd``, ``pg_f``, ``nd``) is NULL in the row when it was NOT
@@ -45,14 +45,26 @@ see ``_glass_kind``) leaves rows of those kinds out; the result carries ``left_o
45
45
  (always), ``left_out_kinds`` (only when > 0) and ``unclassified`` (only when > 0).
46
46
  Rows are classified in Python BEFORE the limit, so the counts cover every match.
47
47
 
48
+ COPIES: one glass redistributed by several catalogs (same name, ``_same_physical_glass``) is
49
+ listed once, after the kind and opt-in screens and before the limit. The first copy in the sort
50
+ order stays and names the catalogs of the removed copies in ``also_in``. A copy is removed only
51
+ when it adds nothing: the optical values (nd, vd, pg_f, and the ``pg_f_anomaly`` computed from
52
+ them) match within the identity tolerance, and every other listed value other than ``catalog`` plus
53
+ the stored transmission data is equal; otherwise both stay. The kept row's ``pg_f_anomaly`` is its
54
+ own. Two nulls are equal, a null against a known value differs. ``matched`` counts
55
+ the rows after removal, ``duplicates_removed`` the rows removed; ``left_out``, ``screened_out``
56
+ and ``unknown_kept`` count before removal. ``dedupe=False`` lists every copy.
57
+
48
58
  An empty result over a WELL-FORMED window is ``ok: True`` with an empty
49
59
  ``candidates`` list — a valid window can legitimately match nothing.
50
60
  """
61
+ import bisect
62
+ import json
51
63
  import math
52
64
 
53
65
  from ..errors import ToolParamError
54
66
  from ..server import ToolSpec
55
- from ._glass_common import check_catalogs_exist, normalize_catalogs
67
+ from ._glass_common import _same_physical_glass, check_catalogs_exist, normalize_catalogs
56
68
  from ._glass_kind import material_kind, parse_exclude
57
69
 
58
70
  _TOOL = "find_glasses"
@@ -83,8 +95,21 @@ _RESULT_COLUMNS = (
83
95
  "formula_name",
84
96
  "min_wave",
85
97
  "max_wave",
86
- "pg_f_anomaly",
98
+ "status",
99
+ "relative_cost",
100
+ "climate",
101
+ "stain",
102
+ "acid",
103
+ "tce",
104
+ "pg_f_anomaly", # stays the last key
87
105
  )
106
+ # A copy is removed only when every one of these (plus the stored transmission data) equals the kept row's.
107
+ _COPY_COLUMNS = ("delta_pg_f", "formula", "formula_name", "min_wave", "max_wave", "status",
108
+ "relative_cost", "climate", "stain", "acid", "tce")
109
+ _STATUS_NAMES = ("standard", "preferred", "obsolete", "special", "melt")
110
+ # Opt-in caps: param -> result column.
111
+ _MAX_FILTERS = (("max_relative_cost", "relative_cost"), ("max_climate", "climate"),
112
+ ("max_stain", "stain"))
88
113
 
89
114
  # The window params: each maps the public ``<field>_min`` / ``<field>_max`` pair
90
115
  # onto a real glass column. ``valid_only=True`` adds the ``<col> IS NOT NULL``
@@ -144,6 +169,39 @@ def _validate_bound(name, value):
144
169
  return float(value)
145
170
 
146
171
 
172
+ def _resolve_dedupe(params):
173
+ """Resolve ``dedupe`` (default True); a bool only, so 0 / 1 / "true" are refused."""
174
+ value = params.get("dedupe", True)
175
+ if not isinstance(value, bool):
176
+ raise ToolParamError(f"'dedupe' must be a bool; got {type(value).__name__}")
177
+ return value
178
+
179
+
180
+ def _remove_copies(rows):
181
+ """Drop later copies of one glass; return ``(kept, also_in, removed)``.
182
+
183
+ A row is a copy of a kept row of the same name when ``_same_physical_glass`` says so AND its
184
+ every other served column and the stored transmission data equal the kept row's (two nulls are
185
+ equal, a null against a known value is not). ``also_in[i]`` lists the catalogs removed into ``kept[i]``, in the order met.
186
+ """
187
+ col = _RESULT_COLUMNS.index
188
+ key = lambda r: (r[0], r[1], r[col("vd")], r[col("pg_f")], r[col("nd")]) # noqa: E731
189
+ same_data = [col(c) for c in _COPY_COLUMNS] + [-1] # -1: the fetched "it" transmission data
190
+ same = lambda a, b: all(a[i] == b[i] for i in same_data) # noqa: E731
191
+ kept, also_in, by_name, removed = [], [], {}, 0
192
+ for r in rows:
193
+ twin = next((i for i in by_name.get(r[1], ())
194
+ if _same_physical_glass(key(kept[i]), key(r)) and same(kept[i], r)), None)
195
+ if twin is None:
196
+ by_name.setdefault(r[1], []).append(len(kept))
197
+ kept.append(r)
198
+ also_in.append([])
199
+ else:
200
+ also_in[twin].append(r[0])
201
+ removed += 1
202
+ return kept, also_in, removed
203
+
204
+
147
205
  def _resolve_limit(params):
148
206
  """Resolve the effective candidate cap (default 50, hard cap 200).
149
207
 
@@ -208,6 +266,51 @@ def _row_to_dict(row):
208
266
  return dict(zip(_RESULT_COLUMNS, row))
209
267
 
210
268
 
269
+ def _transmission(it_json, wavelength):
270
+ """10 mm internal transmission at ``wavelength`` (um): rows rescaled by t ** (10 / d), linear
271
+ between neighbours, None outside the rows' range or with no rows."""
272
+ if not it_json:
273
+ return None
274
+ pts = sorted((w, t ** (10.0 / d)) for w, t, d in json.loads(it_json))
275
+ if not pts or not pts[0][0] <= wavelength <= pts[-1][0]:
276
+ return None
277
+ i = bisect.bisect_left([w for w, _ in pts], wavelength)
278
+ w1, t1 = pts[i]
279
+ if w1 == wavelength or i == 0:
280
+ return round(t1, 4)
281
+ w0, t0 = pts[i - 1]
282
+ return round(t0 + (t1 - t0) * (wavelength - w0) / (w1 - w0), 4)
283
+
284
+
285
+ def _resolve_buildability(params):
286
+ """Validate the opt-in params; return ``(rules, wavelength)``, rules = ``(column, test)`` pairs
287
+ (a glass passes when the value is unknown or the test holds)."""
288
+ rules = []
289
+ if "status" in params:
290
+ raw = params["status"]
291
+ names = {x.strip().lower() for x in raw.split(",")} if isinstance(raw, str) else set()
292
+ if not names or not names <= set(_STATUS_NAMES):
293
+ raise ToolParamError(
294
+ f"'status' must be a comma-separated list of {', '.join(_STATUS_NAMES)}; got {raw!r}"
295
+ )
296
+ rules.append(("status", names.__contains__))
297
+ rules += [(col, lambda v, b=_validate_bound(key, params[key]): v <= b)
298
+ for key, col in _MAX_FILTERS if key in params]
299
+ wavelength = None
300
+ if "transmission_wavelength" in params:
301
+ wavelength = _validate_bound("transmission_wavelength", params["transmission_wavelength"])
302
+ if wavelength <= 0:
303
+ raise ToolParamError("'transmission_wavelength' must be > 0 (micrometres)")
304
+ if "min_transmission" in params:
305
+ low = _validate_bound("min_transmission", params["min_transmission"])
306
+ if not 0.0 <= low <= 1.0:
307
+ raise ToolParamError(f"'min_transmission' must be in [0, 1]; got {low}")
308
+ if wavelength is None:
309
+ raise ToolParamError("'min_transmission' needs 'transmission_wavelength'")
310
+ rules.append(("transmission", lambda v: v >= low))
311
+ return rules, wavelength
312
+
313
+
211
314
  def find_glasses(db_conn, params):
212
315
  """Window-search the glass catalog by Vd / Pg,F / Nd / ΔPg,F (never raises).
213
316
 
@@ -216,8 +319,10 @@ def find_glasses(db_conn, params):
216
319
  catalogs = normalize_catalogs(params.get("catalog"))
217
320
  excluded = parse_exclude(params.get("exclude"))
218
321
  limit = _resolve_limit(params)
322
+ dedupe = _resolve_dedupe(params)
219
323
  sort, order = _resolve_sort(params)
220
324
  clauses, sql_params, queried_fields = _collect_windows(params)
325
+ rules, t_wave = _resolve_buildability(params)
221
326
 
222
327
  if not queried_fields:
223
328
  raise ToolParamError(
@@ -239,7 +344,7 @@ def find_glasses(db_conn, params):
239
344
  # left out covers the whole match set), ordered by the sort field (vd by default, a
240
345
  # stable key for crown/flint scanning; a null field last in either direction) with
241
346
  # catalog and name as tiebreakers for determinism.
242
- cols = ", ".join(_RESULT_COLUMNS)
347
+ cols = ", ".join(_RESULT_COLUMNS + ("it",)) # "it": fetched, never returned
243
348
  direction = " DESC" if order == "descending" else ""
244
349
  sql = (
245
350
  f"SELECT {cols} FROM {_FROM} WHERE {where_sql} "
@@ -250,6 +355,7 @@ def find_glasses(db_conn, params):
250
355
  rows = []
251
356
  left_out_kinds = {}
252
357
  unclassified = 0
358
+ screened_out = unknown_kept = 0
253
359
  for r in all_rows:
254
360
  kind = material_kind(r[0], r[1])
255
361
  if kind in excluded:
@@ -257,18 +363,37 @@ def find_glasses(db_conn, params):
257
363
  continue
258
364
  if kind == "unclassified":
259
365
  unclassified += 1
366
+ if rules:
367
+ vals = [(_transmission(r[-1], t_wave) if c == "transmission"
368
+ else r[_RESULT_COLUMNS.index(c)], ok) for c, ok in rules]
369
+ if any(v is not None and not ok(v) for v, ok in vals):
370
+ screened_out += 1
371
+ continue
372
+ unknown_kept += any(v is None for v, _ in vals)
260
373
  rows.append(r)
261
374
 
375
+ # Copy removal runs after the kind and opt-in screens and BEFORE the limit.
376
+ if dedupe:
377
+ rows, also_in, duplicates_removed = _remove_copies(rows)
378
+ else:
379
+ also_in, duplicates_removed = [[] for _ in rows], 0
262
380
  matched = len(rows)
263
381
  truncated = matched > limit
264
- rows = rows[:limit]
382
+ rows, also_in = rows[:limit], also_in[:limit]
265
383
  candidates = [_row_to_dict(r) for r in rows]
384
+ for cand, r, others in zip(candidates, rows, also_in):
385
+ anomaly = cand.pop("pg_f_anomaly")
386
+ cand["also_in"] = others
387
+ cand["transmission"] = None if t_wave is None else _transmission(r[-1], t_wave)
388
+ cand["pg_f_anomaly"] = anomaly
266
389
 
267
390
  result = {
268
391
  "ok": True,
269
392
  "candidates": candidates,
270
393
  "candidate_count": len(candidates),
271
394
  "matched": matched,
395
+ "duplicates_removed": duplicates_removed,
396
+ "dedupe": dedupe,
272
397
  "truncated": truncated,
273
398
  "sort": sort,
274
399
  "order": order,
@@ -285,6 +410,10 @@ def find_glasses(db_conn, params):
285
410
  result["left_out_kinds"] = left_out_kinds
286
411
  if unclassified:
287
412
  result["unclassified"] = unclassified
413
+ if rules:
414
+ result["screened_out"] = screened_out
415
+ if unknown_kept:
416
+ result["unknown_kept"] = unknown_kept
288
417
  if "delta_pg_f" in queried_fields:
289
418
  # Flag the caveat: delta_pg_f is raw, unvalidated .agf data.
290
419
  result["delta_pg_f_unvalidated"] = True
@@ -315,10 +444,21 @@ FIND_GLASSES_SPEC = ToolSpec(
315
444
  "order=descending reverses; sort=pg_f_anomaly ranks by pg_f minus the normal line "
316
445
  "0.6438 - 0.001682*vd, null when either is null, window pg_f_anomaly_min/max; a "
317
446
  "truncated list carries truncated_note saying what is hidden and how to see it) and cut at limit; matched counts the glasses in the "
318
- "window that are not left out, so truncated means only the first limit of matched are listed. Gotcha: a glass whose computed "
447
+ "window that are not left out, after copies are removed (left_out, screened_out and unknown_kept count before removal), so truncated means only the first limit of matched are listed. Gotcha: a glass whose computed "
319
448
  "field is out-of-band (null) never matches that window, and the delta-Pg,F "
320
449
  "bounds are raw manufacturer data (unvalidated); a stored delta-Pg,F beyond "
321
- "+-0.1 reads as null. See find_glass_pair for "
450
+ "+-0.1 reads as null. Every candidate also carries status (standard, preferred, "
451
+ "obsolete, special, melt), relative_cost, climate, stain, acid, tce and transmission "
452
+ "(internal, 10 mm path, at transmission_wavelength in micrometres, else null). "
453
+ "Opt-in filters, none applied by default: status (comma list), max_relative_cost, "
454
+ "max_climate, max_stain, min_transmission (needs transmission_wavelength); a glass "
455
+ "whose value is unknown is kept and counted in unknown_kept, screened_out counts "
456
+ "the rest, and the thresholds are the caller's. "
457
+ "One glass that several catalogs carry (same name, same optics) is listed once and "
458
+ "the kept row names the other catalogs in also_in; a copy is removed only when "
459
+ "its optical values (nd, vd, pg_f and the pg_f_anomaly computed from them) match "
460
+ "within a small tolerance and every other listed value and the transmission data are "
461
+ "equal, otherwise both stay, and dedupe=false lists every copy. See find_glass_pair for "
322
462
  "matched apochromat pairs. See lookup_glass for one glass's full data."
323
463
  ),
324
464
  kind="glass",
@@ -326,6 +466,7 @@ FIND_GLASSES_SPEC = ToolSpec(
326
466
  "catalog": "string",
327
467
  "exclude": "string",
328
468
  "limit": "integer",
469
+ "dedupe": "boolean",
329
470
  "sort": "string",
330
471
  "order": "string",
331
472
  "vd_min": "number", "vd_max": "number",
@@ -333,6 +474,9 @@ FIND_GLASSES_SPEC = ToolSpec(
333
474
  "nd_min": "number", "nd_max": "number",
334
475
  "delta_pg_f_min": "number", "delta_pg_f_max": "number",
335
476
  "pg_f_anomaly_min": "number", "pg_f_anomaly_max": "number",
477
+ "status": "string", "max_relative_cost": "number", "max_climate": "number",
478
+ "max_stain": "number", "min_transmission": "number",
479
+ "transmission_wavelength": "number",
336
480
  },
337
481
  )
338
482
 
@@ -1,11 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optivibe-reference
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: OptiVibe reference layer: lookup tables + RAG + intent->ZOS-API grounding
5
- License: Apache-2.0
5
+ License-Expression: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/chuang44-tiff/optivibe-mcp
7
7
  Project-URL: Issues, https://github.com/chuang44-tiff/optivibe-mcp/issues
8
- Classifier: License :: OSI Approved :: Apache Software License
9
8
  Classifier: Programming Language :: Python :: 3.11
10
9
  Requires-Python: >=3.11
11
10
  Description-Content-Type: text/markdown