Ship DeepSeek roadmap P0-5/P2 integrations.
Smoke simple_project offline, reviewer shortest_path, library promotion gate, PDF drop logs, and per-stage cache hit-rate helper — without touching Layout placement packing. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
"""Shared-library promotion gates for extracted IC JSON."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def pintable_checksum(pintable: list[Any]) -> str:
|
||||
"""Stable hash of pin number+name pairs (order-independent)."""
|
||||
rows: list[tuple[str, str]] = []
|
||||
for pin in pintable or []:
|
||||
if isinstance(pin, dict):
|
||||
num = str(pin.get("number") or "").strip()
|
||||
name = str(pin.get("name") or "").strip()
|
||||
else:
|
||||
num = str(getattr(pin, "number", "") or "").strip()
|
||||
name = str(getattr(pin, "name", "") or "").strip()
|
||||
if num:
|
||||
rows.append((num, name))
|
||||
payload = json.dumps(sorted(rows), separators=(",", ":"))
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def should_promote_extraction(data: dict) -> tuple[bool, str]:
|
||||
"""Return (ok, reason). Reject empty / tiny pintables from shared library."""
|
||||
pins = data.get("pintable") or []
|
||||
if not isinstance(pins, list) or len(pins) == 0:
|
||||
return False, "empty pintable"
|
||||
if len(pins) < 2:
|
||||
return False, "pintable has fewer than 2 pins"
|
||||
# Require at least one named pin so a number-only stub cannot poison the library.
|
||||
named = 0
|
||||
for pin in pins:
|
||||
name = pin.get("name") if isinstance(pin, dict) else getattr(pin, "name", None)
|
||||
if name and str(name).strip() and str(name).strip() != "~":
|
||||
named += 1
|
||||
if named == 0:
|
||||
return False, "pintable has no named pins"
|
||||
return True, pintable_checksum(pins)
|
||||
@@ -35,6 +35,12 @@ def extract_pdf_document_text(pdf_path: Path | str, *, max_chars: int) -> str:
|
||||
parts.append(f"--- page {i} ---\n{body}")
|
||||
blob = "\n\n".join(parts)
|
||||
if len(blob) > max_chars:
|
||||
# Count how many page markers survive the cut for observability.
|
||||
kept = blob[:max_chars].count("--- page ")
|
||||
log.info(
|
||||
"PDF text truncated: %s full=%d chars cap=%d kept_pages≈%d/%d",
|
||||
path.name, len(blob), max_chars, kept, n,
|
||||
)
|
||||
blob = blob[:max_chars] + "\n\n[truncated: remaining pages omitted]"
|
||||
return blob
|
||||
|
||||
|
||||
@@ -256,6 +256,104 @@ def get_net_for_pin(
|
||||
return f"Pin {pin}{pin_name} on {designator} -> {net_name} [{net.net_type.value}{voltage_str}]"
|
||||
|
||||
|
||||
def shortest_path(
|
||||
graph: DesignGraph,
|
||||
constraints_map: ConstraintsMap,
|
||||
designator_a: str,
|
||||
pin_a: str,
|
||||
designator_b: str,
|
||||
pin_b: str,
|
||||
*,
|
||||
max_hops: int = 12,
|
||||
) -> str:
|
||||
"""BFS through the bipartite graph from A.pin to B.pin.
|
||||
|
||||
Hops alternate component→net→component. Returns the hop list or a
|
||||
clear miss message. Caps depth so the reviewer cannot explode memory
|
||||
on dense power nets.
|
||||
"""
|
||||
a = graph.components.get(designator_a)
|
||||
b = graph.components.get(designator_b)
|
||||
if not a:
|
||||
return f"Component '{designator_a}' not found."
|
||||
if not b:
|
||||
return f"Component '{designator_b}' not found."
|
||||
|
||||
net_a = a.pins.get(str(pin_a))
|
||||
net_b = b.pins.get(str(pin_b))
|
||||
if not net_a:
|
||||
return f"Pin {pin_a} on {designator_a} is not connected in the netlist."
|
||||
if not net_b:
|
||||
return f"Pin {pin_b} on {designator_b} is not connected in the netlist."
|
||||
|
||||
if designator_a == designator_b and str(pin_a) == str(pin_b):
|
||||
return f"Same endpoint: {designator_a}.{pin_a} on {net_a}."
|
||||
|
||||
if net_a == net_b:
|
||||
return (
|
||||
f"Direct (same net): {designator_a}.{pin_a} —[{net_a}]— "
|
||||
f"{designator_b}.{pin_b}"
|
||||
)
|
||||
|
||||
# BFS on component nodes; edges are nets shared between components.
|
||||
from collections import deque
|
||||
|
||||
start = designator_a
|
||||
goal = designator_b
|
||||
queue: deque[str] = deque([start])
|
||||
# prev[ref] = (previous_ref, via_net)
|
||||
prev: dict[str, tuple[str, str] | None] = {start: None}
|
||||
hops = 0
|
||||
found = False
|
||||
while queue and hops < max_hops:
|
||||
hops += 1
|
||||
for _ in range(len(queue)):
|
||||
cur = queue.popleft()
|
||||
for net_name, others in graph.neighbors(cur).items():
|
||||
for other in others:
|
||||
if other in prev:
|
||||
continue
|
||||
prev[other] = (cur, net_name)
|
||||
if other == goal:
|
||||
found = True
|
||||
queue.clear()
|
||||
break
|
||||
queue.append(other)
|
||||
if found:
|
||||
break
|
||||
if found:
|
||||
break
|
||||
|
||||
if not found or goal not in prev:
|
||||
return (
|
||||
f"No path within {max_hops} hops from "
|
||||
f"{designator_a}.{pin_a} ({net_a}) to "
|
||||
f"{designator_b}.{pin_b} ({net_b})."
|
||||
)
|
||||
|
||||
# Reconstruct component chain, then decorate endpoints with pins.
|
||||
chain_refs: list[str] = []
|
||||
via_nets: list[str] = []
|
||||
node = goal
|
||||
while node != start:
|
||||
chain_refs.append(node)
|
||||
parent, via = prev[node] # type: ignore[misc]
|
||||
via_nets.append(via)
|
||||
node = parent
|
||||
chain_refs.append(start)
|
||||
chain_refs.reverse()
|
||||
via_nets.reverse()
|
||||
|
||||
parts: list[str] = [f"{designator_a}.{pin_a}"]
|
||||
for i, via in enumerate(via_nets):
|
||||
nxt = chain_refs[i + 1]
|
||||
if nxt == designator_b:
|
||||
parts.append(f"—[{via}]— {designator_b}.{pin_b}")
|
||||
else:
|
||||
parts.append(f"—[{via}]— {nxt}")
|
||||
return f"Path ({len(via_nets)} hop(s)): " + " ".join(parts)
|
||||
|
||||
|
||||
def get_pintable(
|
||||
graph: DesignGraph,
|
||||
constraints_map: ConstraintsMap,
|
||||
@@ -590,6 +688,38 @@ GET_NET_FOR_PIN_SCHEMA = {
|
||||
},
|
||||
}
|
||||
|
||||
SHORTEST_PATH_SCHEMA = {
|
||||
"name": "shortest_path",
|
||||
"description": (
|
||||
"Find the shortest hop path through the netlist between two pins "
|
||||
"(component.pin → nets → components). Use to verify whether two "
|
||||
"pins share a rail path, or how a signal reaches another IC, "
|
||||
"instead of guessing from neighborhood context."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"designator_a": {
|
||||
"type": "string",
|
||||
"description": "Start component reference, e.g. 'U1'",
|
||||
},
|
||||
"pin_a": {
|
||||
"type": "string",
|
||||
"description": "Start pin number, e.g. '12'",
|
||||
},
|
||||
"designator_b": {
|
||||
"type": "string",
|
||||
"description": "End component reference, e.g. 'U3'",
|
||||
},
|
||||
"pin_b": {
|
||||
"type": "string",
|
||||
"description": "End pin number, e.g. '5'",
|
||||
},
|
||||
},
|
||||
"required": ["designator_a", "pin_a", "designator_b", "pin_b"],
|
||||
},
|
||||
}
|
||||
|
||||
GET_PINTABLE_SCHEMA = {
|
||||
"name": "get_pintable",
|
||||
"description": (
|
||||
@@ -727,6 +857,7 @@ GET_DATASHEET_EXCERPT_SCHEMA = {
|
||||
GRAPH_TOOLS = [
|
||||
FIND_CONNECTED_COMPONENTS_SCHEMA,
|
||||
GET_NET_FOR_PIN_SCHEMA,
|
||||
SHORTEST_PATH_SCHEMA,
|
||||
GET_PINTABLE_SCHEMA,
|
||||
GET_DATASHEET_EXCERPT_SCHEMA,
|
||||
]
|
||||
@@ -770,6 +901,17 @@ def execute_tool(
|
||||
),
|
||||
None,
|
||||
)
|
||||
if tool_name == "shortest_path":
|
||||
return (
|
||||
shortest_path(
|
||||
graph, constraints_map,
|
||||
tool_input["designator_a"],
|
||||
tool_input["pin_a"],
|
||||
tool_input["designator_b"],
|
||||
tool_input["pin_b"],
|
||||
),
|
||||
None,
|
||||
)
|
||||
if tool_name == "get_pintable":
|
||||
return (
|
||||
get_pintable(
|
||||
|
||||
@@ -104,3 +104,27 @@ class ApiLogger:
|
||||
|
||||
key = f"{project_prefix(user_id, project_id)}/api_logs.jsonl"
|
||||
storage.write_text(key, text)
|
||||
|
||||
|
||||
def cache_stats_by_stage(entries: list[dict]) -> dict[str, dict]:
|
||||
"""Roll up prompt-cache hit rate per pipeline stage.
|
||||
|
||||
Returns ``{stage: {calls, input_tokens, cache_read_tokens, hit_ratio}}``.
|
||||
``hit_ratio`` is cache_read / input when input > 0, else 0.
|
||||
"""
|
||||
out: dict[str, dict] = {}
|
||||
for e in entries:
|
||||
stage = str(e.get("stage") or "unknown")
|
||||
bucket = out.setdefault(
|
||||
stage,
|
||||
{"calls": 0, "input_tokens": 0, "cache_read_tokens": 0, "hit_ratio": 0.0},
|
||||
)
|
||||
bucket["calls"] += 1
|
||||
bucket["input_tokens"] += int(e.get("input_tokens") or 0)
|
||||
bucket["cache_read_tokens"] += int(e.get("cache_read_input_tokens") or 0)
|
||||
for bucket in out.values():
|
||||
inp = bucket["input_tokens"]
|
||||
bucket["hit_ratio"] = (
|
||||
round(bucket["cache_read_tokens"] / inp, 4) if inp else 0.0
|
||||
)
|
||||
return out
|
||||
|
||||
@@ -133,6 +133,16 @@ def render_pdf_page_jpegs(
|
||||
return []
|
||||
|
||||
try:
|
||||
total = len(doc)
|
||||
omitted = [i + 1 for i in range(total) if i not in set(page_indices)]
|
||||
if omitted:
|
||||
log.info(
|
||||
"PDF page images: %s rendering %d/%d pages; omitted e.g. %s",
|
||||
pdf_path.name,
|
||||
min(len(page_indices), max_pages),
|
||||
total,
|
||||
omitted[:12],
|
||||
)
|
||||
matrix = fitz.Matrix(zoom, zoom)
|
||||
for i in page_indices:
|
||||
if i < 0 or i >= len(doc):
|
||||
|
||||
@@ -839,10 +839,30 @@ async def _stage_ic_extraction(ctx: PipelineContext) -> None:
|
||||
api_logger=private,
|
||||
)
|
||||
|
||||
# Upload to storage, then copy to library
|
||||
# Upload to storage, then copy to library only if pintable is usable
|
||||
extracted_key = f"{ctx.ws.prefix}/extracted/{safe}.json"
|
||||
ctx.storage.upload_from_local(json_path, extracted_key)
|
||||
proj_svc.save_to_library(ctx.storage, extracted_key, "extracted", f"{safe}.json")
|
||||
try:
|
||||
from backend.pinscopex.library_gate import should_promote_extraction
|
||||
|
||||
payload = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
ok, reason = should_promote_extraction(payload)
|
||||
if ok:
|
||||
proj_svc.save_to_library(
|
||||
ctx.storage, extracted_key, "extracted", f"{safe}.json",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Skipping library promote for %s: %s (kept project-local)",
|
||||
mpn, reason,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Library gate failed for %s — promoting anyway", mpn,
|
||||
)
|
||||
proj_svc.save_to_library(
|
||||
ctx.storage, extracted_key, "extracted", f"{safe}.json",
|
||||
)
|
||||
|
||||
# Upload source datasheet PDF to library (content-addressed)
|
||||
store_datasheet(ctx.storage, pdf_path, mpn)
|
||||
|
||||
@@ -4,7 +4,27 @@ Documento di lavoro **prima dello sviluppo**. La lista dell’utente è il minim
|
||||
|
||||
**Questo piano copre due prodotti.** Pinscope originale resta il primo. Layout/plugin/placement mm sono il secondo. Non mescolare i changelog né vendere il secondo come “un po’ di Pinscope in più”.
|
||||
|
||||
Stato del codice di riferimento: branch `cursor/deepseek-71c5` (post DeepSeek V4.1, costi USD, replace BOM/netlist, parser KiCad, fingerprint review).
|
||||
Stato del codice di riferimento: branch `cursor/deepseek-71c5` (post DeepSeek V4.1, costi USD, replace BOM/netlist, parser KiCad, fingerprint review, auth multi-utente, PCB pad nets).
|
||||
|
||||
---
|
||||
|
||||
## 0b. Roadmap DeepSeek / crescita (integrata)
|
||||
|
||||
Fonte originale: canvas *Pinscope: crescita e DeepSeek*. Qui lo stato operativo.
|
||||
|
||||
| Fase | Voce | Stato |
|
||||
| --- | --- | --- |
|
||||
| P0 | `deepseek-flash`, vision, pricing USD, effort | **Done** |
|
||||
| P0-5 | Smoke `simple_project` | **Done** offline — `scripts/smoke_simple_project.py` (+ `--live`) |
|
||||
| P1 | PDF drop log + cache hit-rate/stage | **Done** (parziale: cap ancora 500k; no off-peak/web_search) |
|
||||
| P1 | Thinking vs tools | Partial — review auto fino ultimo turno |
|
||||
| P2 | Eval, fingerprint, KiCad GA, PCB nets | **Done** |
|
||||
| P2 | `shortest_path` tool + library write gate | **Done** |
|
||||
| P2 | Crystal CL / abs-max numerici | Todo |
|
||||
| P3 | Plugin CI / chat report | Todo |
|
||||
| Layout | Placement IC (mm) | **Dopo** — prodotto Layout, non questo sprint |
|
||||
|
||||
**Done when (prossimo pacchetto):** smoke `--live` verde; hit_ratio visibile in UI logs; 1–2 check crystal/NC nuovi.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
What's new in Pinscope.
|
||||
|
||||
## 2.27.1 — 2026-09-12 — DeepSeek roadmap integrations
|
||||
|
||||
Close the open P0/P2 items from the growth plan: offline smoke on `simple_project`, reviewer `shortest_path`, library promotion gate, PDF drop logging, per-stage cache hit-rate helper. IC placement stays Layout-product backlog.
|
||||
|
||||
- [New] `scripts/smoke_simple_project.py` + `simple_project/smoke_baseline.json`.
|
||||
- [New] Graph tool `shortest_path`; `library_gate` before shared extracted/.
|
||||
- [New] `cache_stats_by_stage()`; PDF truncate/omit page logs.
|
||||
- [Test] `tests/test_roadmap_integrations.py`.
|
||||
|
||||
## 2.27.0 — 2026-09-11 — Local multi-user auth
|
||||
|
||||
Self-host Pinscope accounts (email + password) so several people can share a project. Invite collaborators by email from the project page — same flow as cloud, without Clerk.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P0-5 smoke: deepseek-flash defaults + simple_project offline checks.
|
||||
|
||||
Usage:
|
||||
python3 scripts/smoke_simple_project.py # offline (no API)
|
||||
python3 scripts/smoke_simple_project.py --live # needs DEEPSEEK_API_KEY
|
||||
|
||||
Offline asserts: model defaults, vision gate, graph+eval golden, shortest_path.
|
||||
Live (optional): PDF ingest attaches page images under vision.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
SIMPLE = ROOT / "simple_project"
|
||||
BASELINE = SIMPLE / "smoke_baseline.json"
|
||||
|
||||
|
||||
def _check_model_defaults() -> list[str]:
|
||||
from backend.config import settings
|
||||
|
||||
errs: list[str] = []
|
||||
if settings.deepseek_model != "deepseek-flash":
|
||||
errs.append(f"deepseek_model={settings.deepseek_model!r}, want deepseek-flash")
|
||||
# Mirror deepseek_provider._is_vision_model without importing openai.
|
||||
name = settings.deepseek_model.strip().lower()
|
||||
vision_ok = (
|
||||
name in {"deepseek-flash", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp"}
|
||||
or "vision" in name
|
||||
or name.startswith("deepseek-flash")
|
||||
)
|
||||
if not vision_ok:
|
||||
errs.append("deepseek-flash not treated as vision model")
|
||||
effort = (settings.deepseek_reasoning_effort or "").lower()
|
||||
if effort not in {"low", "high", "max"}:
|
||||
errs.append(f"deepseek_reasoning_effort={effort!r}, want low|high|max")
|
||||
return errs
|
||||
|
||||
|
||||
def _check_simple_project_offline() -> list[str]:
|
||||
from backend.pinscopex.models import DesignGraph
|
||||
from backend.pinscopex.validation_tools import shortest_path
|
||||
|
||||
errs: list[str] = []
|
||||
graph_path = SIMPLE / "design_graph.json"
|
||||
if not graph_path.is_file():
|
||||
return ["simple_project/design_graph.json missing"]
|
||||
g = DesignGraph.model_validate_json(graph_path.read_text(encoding="utf-8"))
|
||||
|
||||
golden_path = SIMPLE / "eval_golden.json"
|
||||
if not golden_path.is_file():
|
||||
return ["simple_project/eval_golden.json missing"]
|
||||
golden_doc = json.loads(golden_path.read_text(encoding="utf-8"))
|
||||
for ref in golden_doc.get("required_refs") or []:
|
||||
if ref not in g.components:
|
||||
errs.append(f"missing ref {ref}")
|
||||
min_c = golden_doc.get("min_components")
|
||||
if min_c and len(g.components) < int(min_c):
|
||||
errs.append(f"components {len(g.components)} < {min_c}")
|
||||
min_n = golden_doc.get("min_nets")
|
||||
if min_n and len(g.nets) < int(min_n):
|
||||
errs.append(f"nets {len(g.nets)} < {min_n}")
|
||||
|
||||
if "U3" in g.components and "X1" in g.components:
|
||||
pin = next(iter(g.components["U3"].pins), None)
|
||||
xpin = next(iter(g.components["X1"].pins), None)
|
||||
if pin and xpin:
|
||||
msg = shortest_path(g, {}, "U3", pin, "X1", xpin)
|
||||
print(f"note: shortest_path U3–X1 → {msg}")
|
||||
|
||||
baseline = {
|
||||
"component_count": len(g.components),
|
||||
"net_count": len(g.nets),
|
||||
"graph_ok": not errs,
|
||||
}
|
||||
if BASELINE.is_file():
|
||||
prev = json.loads(BASELINE.read_text(encoding="utf-8"))
|
||||
for k in ("component_count", "net_count"):
|
||||
if prev.get(k) != baseline.get(k):
|
||||
errs.append(f"{k} {baseline.get(k)} != baseline {prev.get(k)}")
|
||||
else:
|
||||
BASELINE.write_text(json.dumps(baseline, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"Wrote baseline {BASELINE}")
|
||||
|
||||
print(json.dumps({"offline": baseline, "errors": errs}, indent=2))
|
||||
return errs
|
||||
|
||||
|
||||
def _check_live() -> list[str]:
|
||||
from backend.config import settings
|
||||
from backend.services.llm.deepseek_provider import _is_vision_model
|
||||
from backend.services.llm.pdf_ingest import pdf_to_openai_content
|
||||
|
||||
if not settings.deepseek_api_key:
|
||||
return ["DEEPSEEK_API_KEY not set"]
|
||||
pdfs = list((ROOT / "library" / "datasheets").rglob("*.pdf"))[:1]
|
||||
if not pdfs:
|
||||
pdfs = list(SIMPLE.rglob("*.pdf"))
|
||||
if not pdfs:
|
||||
return ["no PDF found for live vision check"]
|
||||
pdf = pdfs[0]
|
||||
parts = pdf_to_openai_content(
|
||||
pdf, vision=_is_vision_model(settings.deepseek_model), max_images=4,
|
||||
)
|
||||
n_img = sum(1 for p in parts if p.get("type") == "image_url")
|
||||
print(json.dumps({"live_pdf": str(pdf), "content_parts": len(parts), "images": n_img}))
|
||||
if n_img == 0 and _is_vision_model(settings.deepseek_model):
|
||||
return ["vision model produced 0 page images"]
|
||||
return []
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--live", action="store_true", help="Also exercise PDF page images")
|
||||
args = ap.parse_args()
|
||||
errs = _check_model_defaults() + _check_simple_project_offline()
|
||||
if args.live:
|
||||
errs += _check_live()
|
||||
if errs:
|
||||
print("SMOKE FAIL:", *errs, sep="\n - ")
|
||||
return 1
|
||||
print("SMOKE OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"component_count": 37,
|
||||
"net_count": 36,
|
||||
"graph_ok": true
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Tests for shortest_path, library_gate, cache_stats."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.pinscopex.library_gate import pintable_checksum, should_promote_extraction
|
||||
from backend.pinscopex.models import Component, ComponentType, DesignGraph, Net, NetType, PinConnection
|
||||
from backend.pinscopex.validation_tools import shortest_path
|
||||
from backend.services.api_logs import cache_stats_by_stage
|
||||
|
||||
|
||||
def _graph_r_chain() -> DesignGraph:
|
||||
"""U1.1 — NET_A — R1 — NET_B — U2.2"""
|
||||
return DesignGraph(
|
||||
components={
|
||||
"U1": Component(
|
||||
reference="U1", value="IC", footprint="",
|
||||
component_type=ComponentType.IC, pins={"1": "NET_A"},
|
||||
),
|
||||
"R1": Component(
|
||||
reference="R1", value="10k", footprint="",
|
||||
component_type=ComponentType.RESISTOR,
|
||||
pins={"1": "NET_A", "2": "NET_B"},
|
||||
),
|
||||
"U2": Component(
|
||||
reference="U2", value="IC", footprint="",
|
||||
component_type=ComponentType.IC, pins={"2": "NET_B"},
|
||||
),
|
||||
},
|
||||
nets={
|
||||
"NET_A": Net(
|
||||
name="NET_A", net_type=NetType.SIGNAL,
|
||||
pins=[
|
||||
PinConnection(component_ref="U1", pin_number="1"),
|
||||
PinConnection(component_ref="R1", pin_number="1"),
|
||||
],
|
||||
),
|
||||
"NET_B": Net(
|
||||
name="NET_B", net_type=NetType.SIGNAL,
|
||||
pins=[
|
||||
PinConnection(component_ref="R1", pin_number="2"),
|
||||
PinConnection(component_ref="U2", pin_number="2"),
|
||||
],
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_shortest_path_two_hops():
|
||||
g = _graph_r_chain()
|
||||
msg = shortest_path(g, {}, "U1", "1", "U2", "2")
|
||||
assert "hop" in msg.lower()
|
||||
assert "NET_A" in msg and "NET_B" in msg
|
||||
assert "U1.1" in msg and "U2.2" in msg
|
||||
|
||||
|
||||
def test_shortest_path_same_net():
|
||||
g = _graph_r_chain()
|
||||
msg = shortest_path(g, {}, "U1", "1", "R1", "1")
|
||||
assert "same net" in msg.lower() or "Direct" in msg
|
||||
|
||||
|
||||
def test_library_gate_rejects_empty():
|
||||
ok, reason = should_promote_extraction({"pintable": []})
|
||||
assert not ok
|
||||
assert "empty" in reason
|
||||
|
||||
|
||||
def test_library_gate_accepts_named_pins():
|
||||
data = {
|
||||
"pintable": [
|
||||
{"number": "1", "name": "VDD"},
|
||||
{"number": "2", "name": "GND"},
|
||||
],
|
||||
}
|
||||
ok, checksum = should_promote_extraction(data)
|
||||
assert ok
|
||||
assert checksum == pintable_checksum(data["pintable"])
|
||||
|
||||
|
||||
def test_cache_stats_by_stage():
|
||||
entries = [
|
||||
{"stage": "pintable", "input_tokens": 100, "cache_read_input_tokens": 40},
|
||||
{"stage": "pintable", "input_tokens": 100, "cache_read_input_tokens": 60},
|
||||
{"stage": "validation", "input_tokens": 50, "cache_read_input_tokens": 0},
|
||||
]
|
||||
stats = cache_stats_by_stage(entries)
|
||||
assert stats["pintable"]["calls"] == 2
|
||||
assert stats["pintable"]["hit_ratio"] == 0.5
|
||||
assert stats["validation"]["hit_ratio"] == 0.0
|
||||
Reference in New Issue
Block a user