From 21e2be1cd037a8a74f8fd3d65a9d4e084d5b9e88 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Sun, 13 Sep 2026 18:01:15 +0200 Subject: [PATCH] Strengthen pintable layout_rules skill and refresh empty extractions. Treat PCB/application layout guidance as a first-class extract, keep those pages in PDF trim, and re-run IC extraction once when rules are still empty under older model_version. Co-authored-by: Cursor --- backend/pinscopex/layout_rules.py | 33 ++++++ backend/services/extraction.py | 47 +++++++- backend/services/pipeline.py | 24 +++- backend/skills_manifest.json | 2 +- docs/piano-implementazione.md | 3 +- frontend/content/changelog.md | 9 ++ skills/extract-pintable/SKILL.md | 177 +++++++++++++++++++--------- skills/extract-pintable/schema.json | 25 +++- skills/extract-pintable/validate.py | 56 +++++++++ tests/test_layout_rules.py | 20 ++++ 10 files changed, 332 insertions(+), 64 deletions(-) diff --git a/backend/pinscopex/layout_rules.py b/backend/pinscopex/layout_rules.py index abbd74b..23c3669 100644 --- a/backend/pinscopex/layout_rules.py +++ b/backend/pinscopex/layout_rules.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any +from packaging.version import Version + KNOWN_KINDS = frozenset({"decoupling_proximity", "thermal_via", "keepout", "length_match"}) @@ -20,6 +22,37 @@ def _num(v: Any) -> float | None: return None +def has_any_layout_rule(raw: object) -> bool: + """True when extraction already produced at least one structured rule.""" + if not isinstance(raw, list): + return False + for row in raw: + if isinstance(row, dict) and str(row.get("kind") or "").strip() in KNOWN_KINDS: + return True + return False + + +def needs_layout_rules_refresh( + data: dict, + *, + min_scan_version: str, +) -> bool: + """True when layout_rules are empty and the extract predates the scan version. + + After a successful extract at ``min_scan_version`` or newer, an empty + ``layout_rules`` list means the datasheet had no guidance — do not loop. + """ + if has_any_layout_rule(data.get("layout_rules")): + return False + ver = str(data.get("model_version") or "0.0.0") + if not min_scan_version or min_scan_version == "0.0.0": + return False + try: + return Version(ver) < Version(min_scan_version) + except Exception: + return True + + def validate_layout_rules(raw: list | None) -> tuple[list[dict], list[str]]: """Return (normalized rows, errors). Empty list is a valid skip.""" if not raw: diff --git a/backend/services/extraction.py b/backend/services/extraction.py index cf69d65..7f21ad5 100644 --- a/backend/services/extraction.py +++ b/backend/services/extraction.py @@ -135,8 +135,36 @@ PINTABLE_TOOL = { }, "layout_rules": { "type": "array", - "description": "Optional PCB layout constraints from typical-application pages. kind must be decoupling_proximity, thermal_via, keepout, or length_match. max_distance_mm only if the PDF states a number — never invent 3 mm or 3W.", - "items": {"type": "object"}, + "description": ( + "PCB layout constraints from typical-application / PCB layout pages. " + "kind: decoupling_proximity | thermal_via | keepout | length_match. " + "Fields: pin, cap_value_hint, max_distance_mm (ONLY if the PDF states a " + "number — never invent 3 mm/JEDEC), same_layer (bool), min_via_count, " + "net_class, note, source_page. Empty array if the PDF has no layout guidance." + ), + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "decoupling_proximity", + "thermal_via", + "keepout", + "length_match", + ], + }, + "pin": {"type": ["string", "null"]}, + "cap_value_hint": {"type": ["string", "null"]}, + "max_distance_mm": {"type": ["number", "null"]}, + "same_layer": {"type": ["boolean", "null"]}, + "min_via_count": {"type": ["integer", "null"]}, + "net_class": {"type": ["string", "null"]}, + "note": {"type": ["string", "null"]}, + "source_page": {"type": ["integer", "null"]}, + }, + "required": ["kind"], + }, }, }, "required": ["component_subtype", "component_subtype_description", "package_info", "pintable"], @@ -257,11 +285,17 @@ _MAX_PDF_PAGES = 120 log = logging.getLogger(__name__) # Keywords used to find relevant pages for each extraction stage. +# Include PCB / typical-application pages so layout_rules can be extracted +# when large datasheets are trimmed to ≤_MAX_PDF_PAGES. _PINTABLE_KEYWORDS = re.compile( r"pin\s*(out|diagram|configuration|description|assignment|function|name|table|map)" r"|ball\s*map|package\s*(pin|drawing|outline)|signal\s+description" r"|absolute\s+maximum|recommended\s+operating|electrical\s+characteristics" - r"|ordering\s+information|device\s+information", + r"|ordering\s+information|device\s+information" + r"|pcb\s+layout|layout\s+(guideline|recommendation|consideration|hint)" + r"|typical\s+application|application\s+(circuit|schematic|information|note)" + r"|reference\s+design|decoupling|bypass\s+capacitor|thermal\s+via" + r"|land\s+pattern|keep[\s\-]?out|place\s+(close|near|within)", re.IGNORECASE, ) @@ -596,7 +630,12 @@ async def extract_pintable( skill_name="extract-pintable", model=model, system=system, - user_text=f"Extract pin table and package info for MPN: {mpn}", + user_text=( + f"Extract pin table, package info, absolute maximum ratings, " + f"and layout_rules (scan PCB layout / typical application / " + f"thermal pages; max_distance_mm only if the PDF states mm) " + f"for MPN: {mpn}" + ), pdf_path=trimmed, output_tool=_to_tool(PINTABLE_TOOL), ) diff --git a/backend/services/pipeline.py b/backend/services/pipeline.py index d37d59d..eb2f962 100644 --- a/backend/services/pipeline.py +++ b/backend/services/pipeline.py @@ -762,6 +762,10 @@ async def _stage_ic_extraction(ctx: PipelineContext) -> None: extracted_dir = ctx.ws.local_path("extracted") # Pre-categorize: workspace cache, library cache, or needs extraction + from backend.config import settings as app_settings + from backend.pinscopex.layout_rules import needs_layout_rules_refresh + + layout_scan_ver = app_settings.get_default_model_version() _ic_cache: dict[str, tuple] = {} _ic_new_count = 0 for mpn in ctx.ic_mpns: @@ -771,13 +775,27 @@ async def _stage_ic_extraction(ctx: PipelineContext) -> None: existing = json.loads(json_path.read_text()) if existing.get("pintable"): ws_ver = existing.get("model_version", "0.0.0") - if not settings_svc.version_is_stale(ws_ver, ctx.min_ver): + if ( + not settings_svc.version_is_stale(ws_ver, ctx.min_ver) + and not needs_layout_rules_refresh( + existing, min_scan_version=layout_scan_ver, + ) + ): _ic_cache[mpn] = ("workspace",) continue lib_key = proj_svc.library_has_extraction(ctx.storage, mpn, min_version=ctx.min_ver) if lib_key: - _ic_cache[mpn] = ("library", lib_key) - continue + # Library hit may still lack layout_rules under the current skill. + try: + lib_payload = ctx.storage.read_json(lib_key) + except Exception: + lib_payload = {} + if not needs_layout_rules_refresh( + lib_payload if isinstance(lib_payload, dict) else {}, + min_scan_version=layout_scan_ver, + ): + _ic_cache[mpn] = ("library", lib_key) + continue _ic_new_count += 1 broker.publish(ctx.project_id, "step_update", diff --git a/backend/skills_manifest.json b/backend/skills_manifest.json index d10d576..8d4555a 100644 --- a/backend/skills_manifest.json +++ b/backend/skills_manifest.json @@ -1,5 +1,5 @@ { - "default_model_version": "1.9.0", + "default_model_version": "1.10.0", "extract-pintable": { "skill_id": "skill_01VMWPZuvuZAe4LmLbmsNWNY", "latest_version": "1784798970179642", diff --git a/docs/piano-implementazione.md b/docs/piano-implementazione.md index 77e1335..09c1619 100644 --- a/docs/piano-implementazione.md +++ b/docs/piano-implementazione.md @@ -25,8 +25,9 @@ Fonte originale: canvas *Pinscope: crescita e DeepSeek*. Qui lo stato operativo. | Layout F1 | Domini / gruppi / satelliti | **Done** — `functional_groups.json` (no mm) | | Layout F1b | Pipeline Placement parallela | **Done** — API/UI `placement_*`, `placement_plan.json` | | Layout F2 | Placement IC packing mm | **Partial** — skeleton gated (`placement_pack.json`); no zone/export yet | +| C4 | `layout_rules` dal datasheet | **Improved** — skill 1.10.0 + trim keywords + cache refresh se vuoto | -**Done when (prossimo pacchetto):** smoke `--live` verde; hit_ratio visibile in UI logs; F2 packing oltre skeleton (collisioni, zone, export). +**Done when (prossimo pacchetto):** smoke `--live` verde; hit_ratio visibile in UI logs; F2 packing oltre skeleton (collisioni, zone, export); più IC library con `layout_rules` numerici. --- diff --git a/frontend/content/changelog.md b/frontend/content/changelog.md index a2eed29..dc8facd 100644 --- a/frontend/content/changelog.md +++ b/frontend/content/changelog.md @@ -2,6 +2,15 @@ What's new in Pinscope. +## 2.28.8 — 2026-09-13 — Stronger layout_rules extraction skill + +Pintable skill now treats PCB / typical-application layout guidance as a first-class extract. Page trim keeps layout keywords; IC cache re-extracts once when `layout_rules` are empty under an older `model_version` (1.10.0+). + +- [Changed] `skills/extract-pintable` — dedicated `layout_rules` step, examples, hard negatives; tighter `validate.py`. +- [Changed] Datasheet page trim includes PCB layout / decoupling / typical-application keywords. +- [Changed] Analysis IC extraction refreshes empties when `model_version` < default (1.10.0). +- [Changed] `default_model_version` → `1.10.0`. + ## 2.28.7 — 2026-09-13 — Placement F2 pack skeleton (gated) Placement pipeline can propose satellite xy only when a `.kicad_pcb` layout exists and a `decoupling_proximity` rule has numeric `max_distance_mm`. No millimetres invented; otherwise `placement_pack.json` is skipped with an explicit reason. diff --git a/skills/extract-pintable/SKILL.md b/skills/extract-pintable/SKILL.md index 6ad77f2..26402b0 100644 --- a/skills/extract-pintable/SKILL.md +++ b/skills/extract-pintable/SKILL.md @@ -1,94 +1,163 @@ --- skill_name: extract-pintable -description: Extract pin table, package info, and component subtype from an IC datasheet PDF. Returns structured data via the save_pintable tool. +description: Extract pin table, package info, absolute-maximum ratings, layout_rules, and component subtype from an IC datasheet PDF. Returns structured data via the save_pintable tool. --- # Extract Pin Table & Variant Info -Extract the pin table and variant/ordering information from a component datasheet and return it as structured JSON via the `save_pintable` tool. +Extract structured data from an IC datasheet and return it via the `save_pintable` tool. + +**Priority order:** (1) complete pin table for the MPN package, (2) `layout_rules` from PCB / typical-application pages, (3) package + abs-max + subtype. ## Steps ### 1. Read the datasheet PDF -The datasheet PDF is provided in the user message. Focus on these sections: -- **Pin configuration / pin assignment table** — This is the primary target. Look for tables listing pin number, pin name, description, and alternate functions. -- **Ordering information / part number decoder** — Decode what each segment of the MPN means (package, temperature grade, packing, output voltage, etc.) -- **Package information** — Pin count, package type (QFP, BGA, SOT-23, etc.) +Focus on these sections (figures count as evidence): +- **Pin configuration / pin assignment table** — primary target +- **Ordering information / part number decoder** +- **Package information** +- **PCB layout / layout guidelines / land pattern notes** +- **Typical application / reference design** (placement callouts near caps, vias, keepouts) +- **Absolute maximum ratings** ### 2. Extract the pin table -For every pin on the component, extract: -- `number` (int or str) — The pin number, or BGA ball coordinate like `"A3"` -- `name` (str) — The pin name exactly as printed in the datasheet (e.g., `"VDD"`, `"PA0/SPI0_CLK"`) -- `description` (str or null) — A brief description if the datasheet provides one -- `functions` (list[str] or null) — Alternate/multiplexed functions if the pin supports them +For every pin: +- `number` (int or str) — pin number, or BGA ball like `"A3"` +- `name` (str) — verbatim from the datasheet (e.g. `"VDD"`, `"PA0/SPI0_CLK"`) +- `description` (str or null) +- `functions` (list[str] or null) — alternate/mux functions -Rules for pin extraction: -- Include ALL pins — power, ground, NC, and signal pins -- Use pin names verbatim from the datasheet — do not rename or normalize -- For multiplexed pins, put the primary name in `name` and alternates in `functions` +Rules: +- Include ALL pins — power, ground, NC, exposed pad / EP +- Names verbatim — do not rename or normalize +- Multiplexed pins: primary in `name`, alternates in `functions` +- If the datasheet has per-package tables, use the package matching the MPN +- Off-by-one pin numbers break everything downstream — double-check -Optional extras (omit if the PDF does not show them): +**Modules vs bare die (critical).** MPNs containing `WROOM`, `WROVER`, `MODULE`, `MOD-`, or `SIP` are *modules*. Extract the **module landing-pad table** (schematic pins). Do **not** extract the SoC/QFN ball map from a nested chip chapter. +- Espressif WROOM: pin 1 is GND. Pin 1 named `ANT`, `CHIP_PU`, or `XTAL_*` means you grabbed the die table — invalid. +- Crystal, RF antenna, and flash on a WROOM module are **inside the can**; they must not appear as schematic pin numbers. + +Optional extras (omit if absent): - `internal_features.pullup_pins` / `esd_clamp_pins` / `analog_switch` from the **block diagram** only. -- `layout_rules` from **PCB layout / typical application** pages. `kind` is only `decoupling_proximity`, `thermal_via`, `keepout`, or `length_match` (intra-pair skew mm). Set `max_distance_mm` only when the document states a number — do not invent JEDEC or USB millimetres. -Rules for pin extraction:` -- If the datasheet has separate tables for different packages, extract for the package matching the MPN -- Pay careful attention to pin numbering — off-by-one errors here break everything downstream -- **Modules vs bare die (critical).** MPNs containing `WROOM`, `WROVER`, `MODULE`, `MOD-`, or `SIP` are *modules*. Extract the **module landing-pad table** (connector pins the schematic uses). Do **not** extract the SoC/QFN ball map from a nested chip chapter or a sibling chip-only PDF. - - Espressif WROOM: pin 1 is GND (often a group of GND pads). Pin 1 named `ANT`, `CHIP_PU`, or `XTAL_*` means you grabbed the bare ESP32 die table — that will mark every module GND as “antenna shorted” and is invalid. - - Crystal, RF antenna, and flash on a WROOM module are **inside the can**; they must not appear as schematic pin numbers. +### 3. Extract layout_rules (required scan — empty OK) -### 3. Extract package info +You **must** look for layout guidance. Emit `layout_rules` as a list. Use `[]` only after scanning layout / application / thermal pages and finding no placement guidance. -Decode the MPN and package details into a single `PackageInfo`: -- `base_family` (str) — The base part family (e.g., `"MSPM0G3507"` from `"MSPM0G3507SPTR"`) -- `package` (str) — Package name (e.g., `"LQFP-48"`, `"SOT-23-5"`) -- `pin_count` (int) — Number of pins -- `description` (str) — Human-readable decoding of the full MPN (e.g., `"MSPM0G3507, 48-pin LQFP, tape & reel"`) +#### Where to look +- Headings: “PCB Layout”, “Layout Guidelines”, “Layout Considerations”, “Board Layout”, “Land Pattern” +- “Typical Application”, “Application Circuit”, “Reference Design” +- Thermal / EP / exposed-pad via recommendations +- Callouts on application figures (“place CIN within 2 mm of VIN”) -Look for an "Ordering Information" or "Device Information" table in the datasheet — most datasheets have one. +#### Allowed `kind` (closed set) +| kind | Use when | +| --- | --- | +| `decoupling_proximity` | Bypass / decoupling / input / output cap near a supply or pin | +| `thermal_via` | Vias under exposed pad / thermal pad / EP | +| `keepout` | Keep foreign nets, digital return, or copper out of a region | +| `length_match` | Intra-pair skew / matched length limit in mm | -### 4. Extract absolute maximum ratings +#### Fields +- `pin` — number or name as printed (`"5"`, `"VIN"`, `"VDD"`, `"EP"`) +- `cap_value_hint` — only if shown (`"100nF"`, `"10µF"`) +- `max_distance_mm` — **number only if the PDF states millimetres** + - OK: “within 2 mm”, “< 5 mm”, “no more than 3 mm from the pin” → `2` / `5` / `3` + - NOT OK as a number: “as close as possible”, “close to the pin”, “adjacent”, “nearby” → set `max_distance_mm: null` and keep the rule with a `note` + - **Never invent** JEDEC, USB, IPC, or “standard 3 mm / 5 mm” distances +- `same_layer` — `true`/`false` only if text says same side / opposite side of the board; else null +- `min_via_count` — integer only if stated (“at least 4 vias”) +- `net_class` / `note` — short quote of the guidance +- `source_page` — 1-based page of the guidance (required when you emit a rule) -Copy the **Absolute Maximum Ratings** table (not Recommended Operating Conditions). For each row that a reviewer would need to compare against the schematic rails: +#### Examples -- `parameter` (str) — as printed (`VCC`, `VIN`, `I/O pin voltage`, `Storage temperature`, …) -- `min` / `max` (number or null) — numeric limit; omit the other side if the table only lists one -- `unit` (str) — `V`, `mA`, `°C`, … -- `source_page` (int) — 1-based datasheet page of that row +Numeric proximity (copy the millimetre from the PDF): -Include supply voltages, pin/input voltages, input current, and temperature. Skip ESD *human-body-model / IEC contact-discharge kV* rows unless they are the only voltage limit given. Do not invent numbers; if the table is a raster with no readable values, return an empty array. +```json +{ + "kind": "decoupling_proximity", + "pin": "VIN", + "cap_value_hint": "10uF", + "max_distance_mm": 2.0, + "same_layer": true, + "note": "Place CIN within 2 mm of VIN", + "source_page": 14 +} +``` -**ESD / TVS / protection ICs (`ic.protection.esd` and similar):** also copy from Electrical Characteristics (not only abs-max): +Proximity without a millimetre (still emit the rule): -- Working / reverse working voltage **Vrwm** (or V_RWM / "operating voltage") as a **signed** min/max in volts — e.g. bidirectional ±13 V is `min: -13`, `max: 13`, `unit: "V"`. -- One extra row whose `parameter` states polarity/topology as printed (`bidirectional`, `unidirectional`, `back-to-back`), `unit: "—"`, min/max omitted. Do **not** infer unidirectional from "IO" pins that list GND as the reference pin. +```json +{ + "kind": "decoupling_proximity", + "pin": "VDD", + "cap_value_hint": "100nF", + "max_distance_mm": null, + "note": "Place decoupling capacitor as close as possible to VDD", + "source_page": 22 +} +``` -### 5. Assign component subtype (taxonomy) +Thermal vias: -The existing IC taxonomy subtypes are provided in the system prompt under `EXISTING IC TAXONOMY SUBTYPES`. Pick the best matching subtype based on the component's MPN, package info, and pin names. +```json +{ + "kind": "thermal_via", + "pin": "EP", + "min_via_count": 4, + "note": "Use at least 4 thermal vias in the exposed pad", + "source_page": 18 +} +``` -If no existing subtype fits, propose a new one following the dot-notation convention (`ic.{category}.{specific}`). +#### Hard negatives +- Do not invent land-pattern pad sizes from the mechanical drawing alone +- Do not emit `length_match` for USB/HDMI/PCIe unless the **this** datasheet states a skew/length number +- Do not use kinds outside the closed set +- One rule per distinct pin/guidance; prefer supply pins that show caps in the application figure -Set the chosen subtype on the `component_subtype` field. +### 4. Extract package info -### 6. Quality checks +- `base_family` — e.g. `"MSPM0G3507"` from `"MSPM0G3507SPTR"` +- `package` — e.g. `"LQFP-48"`, `"SOT-23-5"` +- `pin_count` (int) +- `description` — human-readable MPN decode -Before producing output, verify: -- Pin count matches what the datasheet says for this package -- No duplicate pin numbers -- No pins are missing (compare against the datasheet's stated pin count) -- Pin names look reasonable (not garbled OCR artifacts) +Prefer “Ordering Information” / “Device Information” tables. -### 7. Validate and output +### 5. Extract absolute maximum ratings -Validate your extraction against the output schema: +Copy the **Absolute Maximum Ratings** table (not Recommended Operating Conditions): + +- `parameter`, `min` / `max`, `unit`, `source_page` (1-based) + +Include supply voltages, pin/input voltages, input current, temperature. Skip HBM/IEC kV ESD rows unless they are the only voltage limit. Do not invent numbers. + +**ESD / TVS (`ic.protection.esd` and similar):** also from Electrical Characteristics: +- Vrwm / operating voltage as signed min/max in volts +- One row for polarity/topology as printed (`bidirectional`, …), `unit: "—"` + +### 6. Assign component subtype + +Pick the best dotted subtype from `EXISTING IC TAXONOMY SUBTYPES` (e.g. `ic.mcu`, `ic.power.ldo`). If none fit, propose `ic.{category}.{specific}`. + +### 7. Quality checks + +Before output: +- Pin count matches the package for this MPN +- No duplicate / missing pin numbers +- `layout_rules` scanned (list present; `[]` only if truly no guidance) +- Every emitted rule has a valid `kind`; every numeric `max_distance_mm` comes from the PDF text/figure +- Pin names are not OCR garbage + +### 8. Validate and output ```bash python3 /skills/extract-pintable/validate.py '' ``` -If validation passes, call the `save_pintable` tool with the structured result. -Do NOT write files to disk — use the tool. +If validation passes, call `save_pintable`. Do NOT write files to disk — use the tool. diff --git a/skills/extract-pintable/schema.json b/skills/extract-pintable/schema.json index 9c338c3..3bc5501 100644 --- a/skills/extract-pintable/schema.json +++ b/skills/extract-pintable/schema.json @@ -56,7 +56,30 @@ }, "layout_rules": { "type": "array", - "items": {"type": "object"} + "description": "PCB layout constraints from typical-application / PCB layout pages. Empty if none stated.", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "decoupling_proximity", + "thermal_via", + "keepout", + "length_match" + ] + }, + "pin": {"type": ["string", "null"]}, + "cap_value_hint": {"type": ["string", "null"]}, + "max_distance_mm": {"type": ["number", "null"]}, + "same_layer": {"type": ["boolean", "null"]}, + "min_via_count": {"type": ["integer", "null"]}, + "net_class": {"type": ["string", "null"]}, + "note": {"type": ["string", "null"]}, + "source_page": {"type": ["integer", "null"]} + }, + "required": ["kind"] + } } }, "required": ["component_subtype", "package_info", "pintable"] diff --git a/skills/extract-pintable/validate.py b/skills/extract-pintable/validate.py index 4abca8d..8aa9ecb 100644 --- a/skills/extract-pintable/validate.py +++ b/skills/extract-pintable/validate.py @@ -93,6 +93,62 @@ def validate(data: dict) -> list[str]: kind = row.get("kind") if kind not in kinds: errors.append(f"layout_rules[{i}] unknown kind: {kind!r}") + continue + dist = row.get("max_distance_mm") + if dist is not None and dist is not False: + if isinstance(dist, bool): + errors.append( + f"layout_rules[{i}].max_distance_mm must be a number or null" + ) + elif isinstance(dist, (int, float)): + if float(dist) <= 0: + errors.append( + f"layout_rules[{i}].max_distance_mm must be > 0" + ) + else: + try: + v = float(str(dist).strip()) + except (TypeError, ValueError): + errors.append( + f"layout_rules[{i}].max_distance_mm must be numeric " + f"or null (got {dist!r}) — do not invent distances; " + f"use null when the PDF only says 'close'" + ) + else: + if v <= 0: + errors.append( + f"layout_rules[{i}].max_distance_mm must be > 0" + ) + via = row.get("min_via_count") + if via is not None and via is not False and not isinstance(via, bool): + if isinstance(via, int): + if via <= 0: + errors.append( + f"layout_rules[{i}].min_via_count must be > 0" + ) + else: + try: + iv = int(float(str(via).strip())) + except (TypeError, ValueError): + errors.append( + f"layout_rules[{i}].min_via_count must be an integer " + f"or null (got {via!r})" + ) + else: + if iv <= 0: + errors.append( + f"layout_rules[{i}].min_via_count must be > 0" + ) + page = row.get("source_page") + if page is not None and not isinstance(page, int): + errors.append( + f"layout_rules[{i}].source_page must be an integer or null" + ) + same = row.get("same_layer") + if same is not None and not isinstance(same, bool): + errors.append( + f"layout_rules[{i}].same_layer must be a boolean or null" + ) return errors diff --git a/tests/test_layout_rules.py b/tests/test_layout_rules.py index 57a8bce..e06db20 100644 --- a/tests/test_layout_rules.py +++ b/tests/test_layout_rules.py @@ -33,6 +33,26 @@ def test_empty_list_is_explicit_skip(): assert errors == [] +def test_needs_refresh_when_empty_and_old_version(): + from backend.pinscopex.layout_rules import needs_layout_rules_refresh + + assert needs_layout_rules_refresh( + {"model_version": "1.9.0", "layout_rules": []}, + min_scan_version="1.10.0", + ) + assert not needs_layout_rules_refresh( + {"model_version": "1.10.0", "layout_rules": []}, + min_scan_version="1.10.0", + ) + assert not needs_layout_rules_refresh( + { + "model_version": "1.9.0", + "layout_rules": [{"kind": "decoupling_proximity", "max_distance_mm": None}], + }, + min_scan_version="1.10.0", + ) + + def test_unknown_kind_rejected_and_non_numeric_distance_is_null(): ok, errors = validate_layout_rules([ {"kind": "not_a_kind", "max_distance_mm": 1.0},