From 4937173501862416ce660af27575ae2417e419fa Mon Sep 17 00:00:00 2001 From: Yell-walkalone <12112088@qq.com> Date: Wed, 9 Sep 2026 12:10:01 +0800 Subject: [PATCH 1/7] feat(qor): add ECC QoR v3 engine --- chipcompiler/analysis/qor/__init__.py | 102 +++ chipcompiler/analysis/qor/calibration.py | 100 +++ chipcompiler/analysis/qor/compatibility.py | 57 ++ chipcompiler/analysis/qor/diagnosis.py | 255 ++++++++ chipcompiler/analysis/qor/dimensions.py | 152 +++++ chipcompiler/analysis/qor/evidence.py | 123 ++++ chipcompiler/analysis/qor/feasibility.py | 134 ++++ chipcompiler/analysis/qor/feature_registry.py | 132 ++++ chipcompiler/analysis/qor/features.py | 321 ++++++++++ chipcompiler/analysis/qor/interventions.py | 47 ++ chipcompiler/analysis/qor/loader.py | 255 ++++++++ chipcompiler/analysis/qor/metric_registry.py | 81 +++ chipcompiler/analysis/qor/models.py | 179 ++++++ chipcompiler/analysis/qor/renderer.py | 188 ++++++ chipcompiler/analysis/qor/schema.py | 160 +++++ chipcompiler/analysis/qor/scoring.py | 83 +++ chipcompiler/cli/command_handlers/report.py | 14 +- chipcompiler/engine/flow.py | 13 + chipcompiler/engine/qor_report.py | 595 +----------------- test/analysis/__init__.py | 0 test/analysis/qor/__init__.py | 0 test/analysis/qor/helpers.py | 107 ++++ test/analysis/qor/test_calibration.py | 56 ++ test/analysis/qor/test_diagnosis.py | 86 +++ test/analysis/qor/test_dimensions.py | 135 ++++ test/analysis/qor/test_evidence.py | 64 ++ test/analysis/qor/test_feasibility.py | 82 +++ test/analysis/qor/test_loader.py | 217 +++++++ test/analysis/qor/test_reference_gcd.py | 238 +++++++ test/analysis/qor/test_schema.py | 55 ++ test/analysis/qor/test_scoring.py | 84 +++ test/cli/commands/test_report.py | 13 +- test/test_qor_report.py | 354 ++++------- 33 files changed, 3654 insertions(+), 828 deletions(-) create mode 100644 chipcompiler/analysis/qor/__init__.py create mode 100644 chipcompiler/analysis/qor/calibration.py create mode 100644 chipcompiler/analysis/qor/compatibility.py create mode 100644 chipcompiler/analysis/qor/diagnosis.py create mode 100644 chipcompiler/analysis/qor/dimensions.py create mode 100644 chipcompiler/analysis/qor/evidence.py create mode 100644 chipcompiler/analysis/qor/feasibility.py create mode 100644 chipcompiler/analysis/qor/feature_registry.py create mode 100644 chipcompiler/analysis/qor/features.py create mode 100644 chipcompiler/analysis/qor/interventions.py create mode 100644 chipcompiler/analysis/qor/loader.py create mode 100644 chipcompiler/analysis/qor/metric_registry.py create mode 100644 chipcompiler/analysis/qor/models.py create mode 100644 chipcompiler/analysis/qor/renderer.py create mode 100644 chipcompiler/analysis/qor/schema.py create mode 100644 chipcompiler/analysis/qor/scoring.py create mode 100644 test/analysis/__init__.py create mode 100644 test/analysis/qor/__init__.py create mode 100644 test/analysis/qor/helpers.py create mode 100644 test/analysis/qor/test_calibration.py create mode 100644 test/analysis/qor/test_diagnosis.py create mode 100644 test/analysis/qor/test_dimensions.py create mode 100644 test/analysis/qor/test_evidence.py create mode 100644 test/analysis/qor/test_feasibility.py create mode 100644 test/analysis/qor/test_loader.py create mode 100644 test/analysis/qor/test_reference_gcd.py create mode 100644 test/analysis/qor/test_schema.py create mode 100644 test/analysis/qor/test_scoring.py diff --git a/chipcompiler/analysis/qor/__init__.py b/chipcompiler/analysis/qor/__init__.py new file mode 100644 index 000000000..10a80d4aa --- /dev/null +++ b/chipcompiler/analysis/qor/__init__.py @@ -0,0 +1,102 @@ +"""ECC-QoR draft 3 analysis engine. + +Single source of truth for QoR scoring: build a workspace-level +analysis from the per-step analysis artifacts, emit the versioned +``home/qor_report.json`` contract, and render the CLI text report. +The GUI is a renderer of this report, not a second scorer. +""" + +from datetime import UTC, datetime +from pathlib import Path + +from chipcompiler.analysis.qor import schema as qor_schema +from chipcompiler.analysis.qor.diagnosis import build_diagnoses +from chipcompiler.analysis.qor.dimensions import evaluate_dimensions +from chipcompiler.analysis.qor.evidence import evaluate_evidence +from chipcompiler.analysis.qor.feasibility import evaluate_feasibility +from chipcompiler.analysis.qor.features import compute_features +from chipcompiler.analysis.qor.loader import load_workspace_qor_inputs +from chipcompiler.analysis.qor.models import ( + SCHEMA_VERSION, + SCORING_ENGINE, + InflationView, + QorAnalysis, +) +from chipcompiler.analysis.qor.scoring import evaluate_scalar_summary +from chipcompiler.utility.json import json_write + +REPORT_FILENAME = "qor_report.json" + + +def build_qor_analysis(workspace) -> QorAnalysis: + """Analyze one workspace's current analysis outputs (spec §14.2 facade).""" + inputs = load_workspace_qor_inputs(workspace) + bundle = compute_features(inputs) + dimensions = evaluate_dimensions(bundle, inputs) + feasibility = evaluate_feasibility(inputs) + evidence = evaluate_evidence(inputs, bundle) + scalar = evaluate_scalar_summary(feasibility, dimensions, inputs.profile) + diagnoses = build_diagnoses(feasibility, dimensions, bundle, inputs) + + return QorAnalysis( + schema_version=SCHEMA_VERSION, + scoring_engine=SCORING_ENGINE, + design=inputs.design, + workspace=inputs.workspace_path, + timestamp=datetime.now(UTC).isoformat(), + profile=inputs.profile, + tclk_ns=inputs.tclk_ns, + feasibility=feasibility, + evidence=evidence, + qor_record=dimensions, + scalar_summary=scalar, + diagnoses=diagnoses, + inflation=InflationView( + i_place=bundle.i_place, + i_route=bundle.i_route, + i_total=bundle.i_total, + congestion_severity=bundle.congestion_severity, + compatibility_status=( + bundle.compatibility.status if bundle.compatibility else "INCOMPATIBLE" + ), + ), + flow_steps=dict(inputs.flow_states), + config_warnings=list(inputs.config_warnings), + ) + + +def report_path(workspace) -> Path: + return Path(workspace.directory or "") / "home" / REPORT_FILENAME + + +def write_qor_report(workspace, analysis=None) -> Path: + """Persist the versioned report consumed by ECOS Studio (hard cut).""" + analysis = analysis if analysis is not None else build_qor_analysis(workspace) + payload = analysis.to_dict() + violations = qor_schema.validate_report(payload) + if violations: + raise ValueError(f"qor report failed schema validation: {violations}") + destination = report_path(workspace) + if not json_write(file_path=destination, data=payload): + raise OSError(f"failed to write qor report: {destination}") + return destination + + +def refresh_workspace_qor_report(workspace) -> Path: + """Post-step hook entry point; callers own exception handling.""" + return write_qor_report(workspace) + + +def render_qor_analysis(analysis, inputs=None) -> str: + from chipcompiler.analysis.qor.renderer import render + + return render(analysis, inputs) + + +__all__ = [ + "build_qor_analysis", + "render_qor_analysis", + "refresh_workspace_qor_report", + "report_path", + "write_qor_report", +] diff --git a/chipcompiler/analysis/qor/calibration.py b/chipcompiler/analysis/qor/calibration.py new file mode 100644 index 000000000..60a6209fd --- /dev/null +++ b/chipcompiler/analysis/qor/calibration.py @@ -0,0 +1,100 @@ +"""Calibration functions and default threshold constants (spec §3.2, §8). + +Two mathematical classes exist and are never interchanged: + +* ``psi_cost`` — one-sided monotone non-increasing cost calibration for + lower-is-better quantities that have a preferred plateau (interconnect + inflation, power budget consumption, timing violation scale). Values at + or below the preferred threshold receive full credit; approaching the + geometric lower bound is never penalized. +* ``psi_target`` — two-sided target-interval calibration for parameters + where both under- and over-allocation are sub-optimal (placed core + utilization). ``tau_min > 0`` keeps the under-allocation branch + well-defined, so metrics whose preferred lower bound is zero-adjacent + (inflation >= 1.0) must use ``psi_cost`` instead. + +Default thresholds are CALIBRATED_HEURISTIC values from the spec. They +are engineering defaults, not physical laws; every consumer must present +them as calibrated policy. +""" + +from math import isfinite + + +class CalibrationError(ValueError): + """Raised when calibration parameters violate their ordering contract.""" + + +def _finite(value) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value) + + +def psi_cost(value: float, tau_pref: float, tau_fail: float) -> float: + """One-sided monotone cost calibration (spec eq. 10).""" + if not (_finite(tau_pref) and _finite(tau_fail)) or not tau_pref < tau_fail: + raise CalibrationError(f"require tau_pref < tau_fail, got {tau_pref!r}, {tau_fail!r}") + if not _finite(value): + raise CalibrationError(f"non-finite calibration input: {value!r}") + if value <= tau_pref: + return 1.0 + if value >= tau_fail: + return 0.0 + return (tau_fail - value) / (tau_fail - tau_pref) + + +def psi_target(value: float, tau_min: float, tau_max: float, tau_fail: float) -> float: + """Two-sided target-interval calibration (spec eq. 11).""" + if not (_finite(tau_min) and _finite(tau_max) and _finite(tau_fail)): + raise CalibrationError("target-interval thresholds must be finite numbers") + if not 0 < tau_min <= tau_max < tau_fail: + raise CalibrationError( + f"require 0 < tau_min <= tau_max < tau_fail, got {tau_min!r}, {tau_max!r}, {tau_fail!r}" + ) + if not _finite(value): + raise CalibrationError(f"non-finite calibration input: {value!r}") + if tau_min <= value <= tau_max: + return 1.0 + if value < tau_min: + return value / tau_min + return max(0.0, min(1.0, (tau_fail - value) / (tau_fail - tau_max))) + + +def clamp01(value: float) -> float: + if not _finite(value): + raise CalibrationError(f"non-finite clamp input: {value!r}") + return max(0.0, min(1.0, value)) + + +# --- Default calibration constants (spec §8) --------------------------------- + +# Interconnect inflation (I_total, and I_place under the D-C ladder). +TAU_I_PREF = 1.25 +TAU_I_FAIL = 1.75 + +# Placed core utilization target interval. +TAU_AREA_MIN = 0.45 +TAU_AREA_MAX = 0.70 +TAU_AREA_FAIL = 0.85 + +# Robustness: structural clock imbalance vs multi-corner PVT dispersion. +W_CTS_IMBALANCE = 0.5 +W_PVT_DISPERSION = 0.5 + +# Congestion severity normalization (spec eq. 21). +TAU_RUDY_FAIL = 1.0 +TAU_EGR_MAX_FAIL = 20.0 +TAU_EGR_TOTAL_FAIL = 100.0 + +# Timing fractions of Tclk (USER_PROJECT_CONSTRAINT / CALIBRATED_HEURISTIC). +GUARDBAND_FRACTION = 0.05 +TIMING_FAIL_FRACTION = 0.20 +OVER_PROVISION_FRACTION = 0.20 + +# Signoff-gate severity normalization references (spec eq. 52-54). +TAU_DRC_REF = 100.0 +TAU_LVS_REF = 50.0 +HARDEN_DELIVERABLE_COUNT = 3 # GDS, LEF, LIB + +# Feature watch levels for robustness contributors (POLICY threshold): +# a contributor consuming less than 10% of its dimension budget is clean. +ROBUSTNESS_CONTRIBUTOR_WATCH = 0.10 diff --git a/chipcompiler/analysis/qor/compatibility.py b/chipcompiler/analysis/qor/compatibility.py new file mode 100644 index 000000000..45fad387b --- /dev/null +++ b/chipcompiler/analysis/qor/compatibility.py @@ -0,0 +1,57 @@ +"""Cross-stage net population compatibility (spec §4.3, degraded per D-C). + +Full ``MAPPED_COMPATIBLE`` verification needs a NetMapping contract that +the toolchain does not emit yet: CTS inserts clock-tree nets and neither +``place.map.json`` nor the route DB carries per-net data. Until such an +artifact exists the route side is conservatively INCOMPATIBLE and the +route-referencing ratios evaluate strictly to UNKNOWN; the same-stage +place ratios (one netlist, EXACT population) stay computable. QI then +degrades to the ``I_place`` ladder with downgraded evidence instead of +pretending a reconciled cross-stage identity. +""" + +from dataclasses import dataclass, field + +EXACT_COMPATIBLE = "EXACT_COMPATIBLE" +MAPPED_COMPATIBLE = "MAPPED_COMPATIBLE" +INCOMPATIBLE = "INCOMPATIBLE" + + +@dataclass(frozen=True) +class Compatibility: + status: str + assumptions: str = "" + net_mapping: dict = field(default_factory=dict) + + def to_contract(self) -> dict: + contract = {"status": self.status} + if self.assumptions: + contract["assumptions"] = self.assumptions + if self.net_mapping: + contract["net_mapping"] = dict(self.net_mapping) + return contract + + +def evaluate_route_compatibility(*, cts_transformed: bool) -> Compatibility: + """Classify the place→route population used by route-side ratios. + + ``cts_transformed`` is True when the CTS step completed and inserted + physical clock-tree buffering (or its counts are unknown, which is + treated conservatively as transformed). + """ + if cts_transformed: + return Compatibility( + status=INCOMPATIBLE, + assumptions=( + "CTS inserted clock-tree nets and no verified net mapping " + "exists yet, so route populations cannot be reconciled with " + "placement; route-referencing inflation ratios stay UNKNOWN." + ), + ) + return Compatibility( + status=EXACT_COMPATIBLE, + assumptions=( + "No CTS transformation is present; route and placement nets " + "share one population (identity mapping)." + ), + ) diff --git a/chipcompiler/analysis/qor/diagnosis.py b/chipcompiler/analysis/qor/diagnosis.py new file mode 100644 index 000000000..b07aaf66d --- /dev/null +++ b/chipcompiler/analysis/qor/diagnosis.py @@ -0,0 +1,255 @@ +"""Deterministic diagnosis and intervention engine (spec §11). + +Observations use strictly non-causal language: cross-stage deltas are +"consumed" or "associated", never "caused". Severities are closed-form; +signoff gate violations always outrank quality bottlenecks +(Sgate >= 0.80 > Sbottleneck) while keeping magnitude information. +""" + +from chipcompiler.analysis.qor import interventions as iv +from chipcompiler.analysis.qor.calibration import ( + HARDEN_DELIVERABLE_COUNT, + OVER_PROVISION_FRACTION, + TAU_DRC_REF, + TAU_LVS_REF, + TIMING_FAIL_FRACTION, + clamp01, +) +from chipcompiler.analysis.qor.models import Diagnosis, SupportingMetric + +# Quality dimensions start counting as bottlenecks below this coordinate. +BOTTLENECK_THRESHOLD = 80.0 + + +def build_diagnoses(feasibility, dimensions, bundle, inputs) -> list: + diagnoses = [] + for gate in feasibility.gates: + if gate.state == "failed": + diagnoses.append(_gate_diagnosis(gate, inputs)) + if feasibility.status != "PHYSICAL_FAIL": + for dimension in dimensions.values(): + if dimension.value is not None and dimension.value < BOTTLENECK_THRESHOLD: + diagnoses.append(_bottleneck_diagnosis(dimension)) + diagnoses.extend(_opportunity_diagnoses(bundle, inputs)) + if bundle.congestion_severity is not None and bundle.congestion_severity > 0: + diagnoses.append(_congestion_diagnosis(bundle)) + return sorted(diagnoses, key=lambda d: (-d.severity, d.diagnosis_id)) + + +def _supporting(inputs, metric_id, unit=""): + record = inputs.metrics.get(metric_id) + if record is None: + return None + return SupportingMetric( + name=metric_id, + value=record.value, + unit=unit or record.unit, + source=inputs.source_path(metric_id), + ) + + +def _gate_magnitude(gate, inputs): + """Normalized violation magnitude in (0, 1] for a failed gate (eq. 49-54).""" + value = inputs.value(gate.metrics[0]) if gate.metrics else None + if value is None: + return 1.0 + if gate.id in ("GATE_SETUP_SLACK", "GATE_HOLD_SLACK"): + scale = TIMING_FAIL_FRACTION * inputs.tclk_ns if inputs.tclk_ns else None + if not scale: + return 1.0 + return max(clamp01(abs(value) / scale), 1e-9) + if gate.id in ("GATE_SETUP_NVP", "GATE_HOLD_NVP"): + # Per-endpoint normalization needs the endpoint population, which + # the artifacts do not carry; any violation counts as full band. + return 1.0 + if gate.id == "GATE_DRC": + return max(clamp01(value / TAU_DRC_REF), 1e-9) + if gate.id == "GATE_LVS": + return max(clamp01(value / TAU_LVS_REF), 1e-9) + if gate.id == "GATE_HARDEN_ARTIFACTS": + return max(clamp01(value / HARDEN_DELIVERABLE_COUNT), 1e-9) + return 1.0 + + +def _gate_diagnosis(gate, inputs) -> Diagnosis: + magnitude = _gate_magnitude(gate, inputs) + severity = 0.80 + 0.20 * magnitude + support = [ + metric + for metric in (_supporting(inputs, metric_id) for metric_id in gate.metrics) + if metric is not None + ] + if gate.id in ("GATE_SETUP_SLACK", "GATE_HOLD_SLACK"): + interpretation = ( + f"{gate.stage} signed worst slack is negative; {abs(support[0].value):g} ns of " + "violation magnitude is present in the signoff timing reports." + if support + else f"{gate.stage} signoff slack gate failed." + ) + hypothesis = ( + "Review the failing signoff timing paths and evaluate placement/routing " + "adjustments correlated with slack recovery." + ) + validation = "Rerun STA after implementation changes to confirm slack >= 0." + else: + interpretation = ( + f"{gate.stage} signoff reports {support[0].value:g} violation(s); " + "tapeout feasibility is blocked." + if support + else f"{gate.stage} signoff gate failed." + ) + hypothesis = ( + f"Inspect the {gate.stage} violation reports and evaluate a corrective rerun " + "of the responsible implementation step." + ) + validation = f"Rerun the {gate.stage} signoff check and confirm a clean result." + return Diagnosis( + diagnosis_id=f"diag.signoff.{gate.id.lower()}", + state="FAIL", + severity=severity, + diagnosis_confidence="HIGH", + trigger_features=[], + supporting_metrics=support, + interpretation=interpretation, + affected_dimensions=[gate.stage.lower()], + interventions=[ + iv.make( + f"Intervention hypothesis: {hypothesis}", + iv.TIER_1, + confidence="MEDIUM", + validation_procedure=validation, + ) + ], + intervention_confidence="MEDIUM", + validation_required=validation, + ) + + +def _bottleneck_diagnosis(dimension) -> Diagnosis: + severity = clamp01((100.0 - dimension.value) / 100.0) + knob = _DIMENSION_KNOBS.get(dimension.key, {}) + hypothesis = knob.get( + "hypothesis", + f"Evaluate parameter adjustments correlated with {dimension.key} quality recovery.", + ) + validation = knob.get( + "validation", "Rerun the flow after the change and compare the affected QoR dimension." + ) + return Diagnosis( + diagnosis_id=f"diag.quality.{dimension.key}", + state=dimension.state if dimension.state in ("WATCH", "FAIL") else "WATCH", + severity=severity, + diagnosis_confidence="HIGH", + trigger_features=[feature.feature_id for feature in dimension.features], + supporting_metrics=[], + interpretation=( + f"{dimension.key} quality coordinate {dimension.value:.1f} is below the " + f"{BOTTLENECK_THRESHOLD:g}-point bottleneck threshold." + ), + affected_dimensions=[dimension.key], + interventions=[ + iv.make( + f"Intervention hypothesis: {hypothesis}", + iv.TIER_2, + confidence=knob.get("confidence", "LOW"), + parameter_knob=knob.get("knob"), + validation_procedure=validation, + ) + ], + intervention_confidence=knob.get("confidence", "LOW"), + validation_required=validation, + ) + + +_DIMENSION_KNOBS = { + "interconnect": { + "hypothesis": ( + "evaluate increased router search depth correlated with wirelength reduction." + ), + "knob": "route.dr_search_depth", + "validation": "Requires a trial reroute to confirm wirelength reduction.", + "confidence": "LOW", + }, + "area": { + "hypothesis": "evaluate core utilization targets closer to the placement sweet spot.", + "validation": "Rerun floorplacement and compare area quality.", + }, + "power": { + "hypothesis": "review the declared power budget against measured signoff power.", + "validation": "Rerun STA power analysis after power-focused optimization.", + }, + "robustness": { + "hypothesis": "evaluate clock tree balancing and multi-corner skew targets.", + "validation": "Rerun CTS and signoff STA to compare dispersion.", + }, +} + + +def _opportunity_diagnoses(bundle, inputs) -> list: + diagnoses = [] + ws = inputs.value("sta_setup_wns") + tclk = inputs.tclk_ns + if ws is not None and tclk: + over = OVER_PROVISION_FRACTION * tclk + if ws > over: + severity = clamp01((ws - over) / (tclk - over)) + support = _supporting(inputs, "sta_setup_wns", "ns") + validation = ( + "Requires downsizing trials with signoff STA re-check to confirm timing holds." + ) + diagnoses.append( + Diagnosis( + diagnosis_id="diag.timing.over_provisioned", + state="OPPORTUNITY", + severity=severity, + diagnosis_confidence="HIGH", + trigger_features=["F_STA_HEADROOM"], + supporting_metrics=[support] if support else [], + interpretation=( + f"Timing margin (+{ws:g}ns) exceeds the over-provisioning threshold " + f"({over:g}ns); the design appears over-constrained." + ), + affected_dimensions=["timing", "area", "power"], + interventions=[ + iv.make( + "Intervention hypothesis: downsize drive strengths to recover " + "power and area correlated with the excess margin.", + iv.TIER_3, + confidence="MEDIUM", + validation_procedure=validation, + ) + ], + intervention_confidence="MEDIUM", + validation_required=validation, + ) + ) + return diagnoses + + +def _congestion_diagnosis(bundle) -> Diagnosis: + severity = min(1.0, bundle.congestion_severity) + validation = "Rerun placement/routing after density adjustments and compare overflow." + return Diagnosis( + diagnosis_id="diag.place.congestion", + state="WATCH", + severity=severity, + diagnosis_confidence="HIGH", + trigger_features=["F_PL_CONG_CONC", "S_CONG"], + supporting_metrics=[], + interpretation=( + "Placement congestion proxies are associated with elevated routing demand " + f"(severity {severity:g})." + ), + affected_dimensions=["interconnect"], + interventions=[ + iv.make( + "Intervention hypothesis: evaluate placement density or routing capacity " + "adjustments correlated with overflow reduction.", + iv.TIER_2, + confidence="LOW", + validation_procedure=validation, + ) + ], + intervention_confidence="LOW", + validation_required=validation, + ) diff --git a/chipcompiler/analysis/qor/dimensions.py b/chipcompiler/analysis/qor/dimensions.py new file mode 100644 index 000000000..c8f572059 --- /dev/null +++ b/chipcompiler/analysis/qor/dimensions.py @@ -0,0 +1,152 @@ +"""Physical QoR record dimensions Q_T, Q_I, Q_A, Q_P, Q_R (spec §8). + +Qphys = (Q_T, Q_I, Q_A, Q_P, Q_R) with each coordinate in [0, 100] or +None (explicit UNKNOWN). Physical quality is never conflated with +signoff feasibility: a failing design still reports continuous quality +coordinates while the scalar projection is vetoed to zero elsewhere. +""" + +from chipcompiler.analysis.qor.calibration import ( + GUARDBAND_FRACTION, + OVER_PROVISION_FRACTION, + TAU_AREA_FAIL, + TAU_AREA_MAX, + TAU_AREA_MIN, + TAU_I_FAIL, + TAU_I_PREF, + TIMING_FAIL_FRACTION, + W_CTS_IMBALANCE, + W_PVT_DISPERSION, + clamp01, + psi_cost, + psi_target, +) +from chipcompiler.analysis.qor.compatibility import EXACT_COMPATIBLE +from chipcompiler.analysis.qor.models import QorDimension + +_QUALITY_DIMENSIONS = ("timing", "interconnect", "area", "power", "robustness") + + +def _quality_state(value): + """Policy mapping from a continuous [0, 100] quality coordinate.""" + if value is None: + return "UNKNOWN" + if value >= 80.0: + return "PASS" + if value >= 60.0: + return "WATCH" + return "FAIL" + + +def _timing_state(ws, tclk): + """TimingState classification (spec eq. 23).""" + if ws is None or not tclk: + return "UNKNOWN" + if ws < 0: + return "FAIL" + if ws < GUARDBAND_FRACTION * tclk: + return "WATCH" + if ws > OVER_PROVISION_FRACTION * tclk: + return "OPPORTUNITY" + return "PASS" + + +def _timing(bundle, inputs): + ws = inputs.value("sta_setup_wns") + tclk = inputs.tclk_ns + value = None + if ws is not None and tclk: + if ws < 0: + value = 50.0 * max(0.0, 1.0 - abs(ws) / (TIMING_FAIL_FRACTION * tclk)) + else: + value = 50.0 + 50.0 * clamp01(ws / (GUARDBAND_FRACTION * tclk)) + features = [bundle.features["F_STA_HEADROOM"]] + return QorDimension(key="timing", value=value, state=_timing_state(ws, tclk), features=features) + + +def _interconnect(bundle, inputs): + # D-C ladder: score I_total under verified compatibility, else the + # same-netlist I_place with the degraded evidence carried by the + # compatibility contract on the feature records. + inflation = ( + bundle.i_total if bundle.compatibility.status == EXACT_COMPATIBLE else bundle.i_place + ) + value = None + if inflation is not None: + congestion = bundle.congestion_severity if bundle.congestion_severity is not None else 0.0 + value = 100.0 * psi_cost(inflation, TAU_I_PREF, TAU_I_FAIL) * (1.0 - min(1.0, congestion)) + features = [ + bundle.features["F_PL_I_PLACE"], + bundle.features["F_RT_I_ROUTE"], + bundle.features["F_RT_I_TOTAL"], + bundle.features["F_PL_CONG_CONC"], + bundle.features["F_RT_VIA_DENSITY"], + bundle.features["F_RCX_CPL_FRAC"], + ] + return QorDimension( + key="interconnect", value=value, state=_quality_state(value), features=features + ) + + +def _area(bundle, inputs): + utilization = inputs.value("core_utilization") + value = None + if utilization is not None: + value = 100.0 * psi_target(utilization, TAU_AREA_MIN, TAU_AREA_MAX, TAU_AREA_FAIL) + return QorDimension( + key="area", + value=value, + state=_quality_state(value), + features=[bundle.features["F_PLAN_DENSITY"]], + ) + + +def _power(bundle, inputs): + budget = inputs.power_budget_uw + total = inputs.power_total_uw + value = None + if budget is not None and total is not None: + value = 100.0 * clamp01((budget - total) / budget) + return QorDimension( + key="power", + value=value, + state=_quality_state(value), + features=[bundle.features["F_SYN_LEAK_FRAC"]], + ) + + +def _robustness(bundle, inputs): + imbalance = bundle.cts_imbalance + candidates = [d for d in (bundle.pvt_setup_disp, bundle.pvt_hold_disp) if d is not None] + pvt = max(candidates) if candidates else None + + contributors = [] + if imbalance is not None: + contributors.append(W_CTS_IMBALANCE * imbalance) + if pvt is not None: + contributors.append(W_PVT_DISPERSION * min(1.0, pvt)) + value = None + if contributors: + used_weight = W_CTS_IMBALANCE if imbalance is not None else 0.0 + used_weight += W_PVT_DISPERSION if pvt is not None else 0.0 + # Re-normalize over available contributors (spec §8.2.5 fallback). + value = 100.0 * (1.0 - sum(contributors) / used_weight) + return QorDimension( + key="robustness", + value=value, + state=_quality_state(value), + features=[ + bundle.features["F_CTS_BUF_IMBAL"], + bundle.features["F_STA_PVT_SETUP_DISP"], + bundle.features["F_STA_PVT_HOLD_DISP"], + bundle.features["F_STA_PVT_MAX_DISP"], + ], + ) + + +def evaluate_dimensions(bundle, inputs) -> dict: + dimensions = {} + for builder in (_timing, _interconnect, _area, _power, _robustness): + dimension = builder(bundle, inputs) + dimensions[dimension.key] = dimension + return dimensions diff --git a/chipcompiler/analysis/qor/evidence.py b/chipcompiler/analysis/qor/evidence.py new file mode 100644 index 000000000..8059fdf3e --- /dev/null +++ b/chipcompiler/analysis/qor/evidence.py @@ -0,0 +1,123 @@ +"""Evidence Completeness Index (spec §10.4). + +IE is an internal engineering completeness index, not a probability. +The multiplicative composition implements a conjunctive requirement: +correlated extraction anomalies compound pessimistically on purpose so +that corrupt evidence downgrades confidence instead of averaging away. +Every zero-denominator condition evaluates to NOT_APPLICABLE, never to +division-by-zero or a fake perfect score. +""" + +from chipcompiler.analysis.qor.models import Evidence + +_HIGH = "HIGH" +_MODERATE = "MODERATE" +_LIMITED = "LIMITED" +_INSUFFICIENT = "INSUFFICIENT" +_NOT_VERIFIED = "NOT_VERIFIED" + + +def _ratio_or_none(numerator, denominator): + if numerator is None or denominator is None or denominator <= 0: + return None + return max(0.0, min(1.0, numerator / denominator)) + + +def evaluate_evidence(inputs, bundle) -> Evidence: + integrity = _integrity(inputs) + coverage = _coverage(inputs) + consistency = _consistency(inputs, bundle) + + active = [ + component for component in (integrity, coverage, consistency) if component is not None + ] + if not active: + return Evidence( + index=None, + state=_NOT_VERIFIED, + integrity=integrity, + coverage=coverage, + consistency=consistency, + ) + index = 100.0 + for component in active: + index *= component + return Evidence( + index=index, + state=_state(index), + integrity=integrity, + coverage=coverage, + consistency=consistency, + ) + + +def _state(index: float) -> str: + if index >= 90.0: + return _HIGH + if index >= 70.0: + return _MODERATE + if index >= 50.0: + return _LIMITED + return _INSUFFICIENT + + +def _integrity(inputs): + """Data integrity: payload parse health plus selector provenance (eq. 41).""" + expected = len(inputs.analyzed_steps) + inputs.parse_failures + if expected <= 0: + return None + failures = inputs.parse_failures + inputs.invalid_selector_count + return max(0.0, min(1.0, 1.0 - failures / expected)) + + +def _coverage(inputs): + """Corner population: STA corners and RCX SPEF coverage (eq. 42).""" + components = [] + sta = _ratio_or_none(_sta_loaded(inputs), inputs.sta_expected_corners) + if sta is not None: + components.append(sta) + rcx = _ratio_or_none(inputs.rcx_spef_count, inputs.rcx_expected_spef) + if rcx is not None: + components.append(rcx) + if not components: + return None + return sum(components) / len(components) + + +def _sta_loaded(inputs): + expected = inputs.sta_expected_corners + if expected is None: + return None + missing = inputs.value("sta_missing_corner_count") + if missing is None: + return None + return expected - missing + + +def _consistency(inputs, bundle): + """Semantic consistency checks C1-C3 (eq. 44-46).""" + checks = [] + + # C1: realized RWL >= HPWL baseline when populations are compatible. + if bundle.compatibility.status != "INCOMPATIBLE": + rwl = inputs.value("route_wirelength") + hpwl = inputs.value("place_hpwl") + if rwl is not None and hpwl is not None: + checks.append(rwl >= hpwl) + + # C2: (WS >= 0) <=> (NVP == 0) under one scope — both metrics come + # from the same STA payload over the same configured corners. + ws = inputs.value("sta_setup_wns") + nvp = inputs.value("sta_setup_violation_count") + if ws is not None and nvp is not None: + checks.append((ws >= 0.0) == (nvp == 0)) + + # C3: vias imply routed wirelength (one-way topological sanity). + vias = inputs.value("route_via_count") + rwl = inputs.value("route_wirelength") + if vias is not None and rwl is not None: + checks.append(not (vias > 0) or rwl > 0) + + if not checks: + return None + return sum(1.0 for passed in checks if passed) / len(checks) diff --git a/chipcompiler/analysis/qor/feasibility.py b/chipcompiler/analysis/qor/feasibility.py new file mode 100644 index 000000000..55b13ce22 --- /dev/null +++ b/chipcompiler/analysis/qor/feasibility.py @@ -0,0 +1,134 @@ +"""The seven authoritative physical signoff gates (spec §10). + +Gate evaluation distinguishes a physical failure from missing evidence: +a missing report or corner never becomes PHYSICAL_FAIL. Reduction order +is strict: any failed gate vetoes everything; corrupt evidence yields +UNKNOWN; omitted verification yields NOT_VERIFIED; only all-pass (or +waived) yields PASS. +""" + +from chipcompiler.analysis.qor.metric_registry import clamped_wns +from chipcompiler.analysis.qor.models import Feasibility, FeasibilityGate, SlackView +from chipcompiler.data import StateEnum + +_PHYSICAL_FAIL = "PHYSICAL_FAIL" +_UNKNOWN = "UNKNOWN" +_NOT_VERIFIED = "NOT_VERIFIED" +_PASS = "PASS" + + +def _slack_gate(gate_id, stage, step_value, ws_metric, tns_metric, nvp_metric, inputs): + state = inputs.step_state(step_value) + ws = inputs.value(ws_metric) + worst_corner_record = ( + inputs.metrics.get("sta_worst_setup_corner") if ws_metric.startswith("sta_setup") else None + ) + if state != StateEnum.Success.value: + return FeasibilityGate( + id=gate_id, + stage=stage, + state="unavailable", + predicate=f"{ws_metric} >= 0.0", + blocks_tapeout=True, + metrics=[ws_metric], + availability="not_verified", + ) + if ws is None: + return FeasibilityGate( + id=gate_id, + stage=stage, + state="unavailable", + predicate=f"{ws_metric} >= 0.0", + blocks_tapeout=True, + metrics=[ws_metric], + availability="corrupt", + ) + slack = SlackView( + ws_ns=ws, + wns_ns=clamped_wns(ws), + tns_ns=inputs.value(tns_metric), + nvp=int(inputs.value(nvp_metric)) if inputs.value(nvp_metric) is not None else None, + worst_corner=worst_corner_record.value if worst_corner_record is not None else None, + ) + return FeasibilityGate( + id=gate_id, + stage=stage, + state="passed" if ws >= 0.0 else "failed", + predicate=f"{ws_metric} >= 0.0", + blocks_tapeout=True, + metrics=[ws_metric], + timing_slack=slack, + ) + + +def _count_gate(gate_id, stage, step_value, metric_id, inputs): + state = inputs.step_state(step_value) + if state != StateEnum.Success.value: + return FeasibilityGate( + id=gate_id, + stage=stage, + state="unavailable", + predicate=f"{metric_id} == 0", + blocks_tapeout=True, + metrics=[metric_id], + availability="not_verified", + ) + value = inputs.value(metric_id) + if value is None: + return FeasibilityGate( + id=gate_id, + stage=stage, + state="unavailable", + predicate=f"{metric_id} == 0", + blocks_tapeout=True, + metrics=[metric_id], + availability="corrupt", + ) + return FeasibilityGate( + id=gate_id, + stage=stage, + state="passed" if value == 0 else "failed", + predicate=f"{metric_id} == 0", + blocks_tapeout=True, + metrics=[metric_id], + ) + + +def evaluate_feasibility(inputs) -> Feasibility: + gates = [ + _count_gate("GATE_DRC", "DRC", "drc", "drc_count", inputs), + _count_gate("GATE_LVS", "LVS", "lvs", "lvs_count", inputs), + _slack_gate( + "GATE_SETUP_SLACK", + "STA", + "sta", + "sta_setup_wns", + "sta_setup_tns", + "sta_setup_violation_count", + inputs, + ), + _slack_gate( + "GATE_HOLD_SLACK", + "STA", + "sta", + "sta_hold_wns", + "sta_hold_tns", + "sta_hold_violation_count", + inputs, + ), + _count_gate("GATE_SETUP_NVP", "STA", "sta", "sta_setup_violation_count", inputs), + _count_gate("GATE_HOLD_NVP", "STA", "sta", "sta_hold_violation_count", inputs), + _count_gate( + "GATE_HARDEN_ARTIFACTS", "HARDEN", "Harden", "harden_artifact_missing_count", inputs + ), + ] + + if any(gate.state == "failed" for gate in gates): + status = _PHYSICAL_FAIL + elif any(gate.availability == "corrupt" for gate in gates): + status = _UNKNOWN + elif any(gate.availability == "not_verified" for gate in gates): + status = _NOT_VERIFIED + else: + status = _PASS + return Feasibility(status=status, gates=gates) diff --git a/chipcompiler/analysis/qor/feature_registry.py b/chipcompiler/analysis/qor/feature_registry.py new file mode 100644 index 000000000..e13fd4201 --- /dev/null +++ b/chipcompiler/analysis/qor/feature_registry.py @@ -0,0 +1,132 @@ +"""Canonical registry of derived features (spec §5, §6). + +Epistemic classification strings come from the spec's seven tiers; they +are audit metadata attached to every feature record, not code structure. +""" + + +def _feature(formula, classification, semantic_class, inputs): + return { + "formula": formula, + "classification": classification, + "semantic_class": semantic_class, + "input_metric_ids": inputs, + } + + +FEATURE_REGISTRY = { + "F_SYN_LEAK_FRAC": _feature( + "synthesis_power_leakage_uw / (synthesis_power_dynamic_uw + synthesis_power_leakage_uw)", + "EXACT_TRANSFORMATION", + "composition_fraction", + ["synthesis_power_dynamic_uw", "synthesis_power_leakage_uw"], + ), + "F_PLAN_DENSITY": _feature( + "synthesis_cell_area / core_area", + "DERIVED_ENGINEERING_FEATURE", + "planning_density_ratio", + ["synthesis_cell_area", "core_area"], + ), + "F_PL_I_PLACE": _feature( + "place_grwl / place_hpwl", + "DERIVED_ENGINEERING_FEATURE", + "bound_proximity", + ["place_grwl", "place_hpwl"], + ), + "F_PL_CONG_CONC": _feature( + "place_congestion_egr_overflow_max / place_congestion_egr_overflow_total", + "DERIVED_ENGINEERING_FEATURE", + "spatial_concentration", + ["place_congestion_egr_overflow_max", "place_congestion_egr_overflow_total"], + ), + "F_CTS_BUF_IMBAL": _feature( + "(clock_path_max_buffer - clock_path_min_buffer) / clock_path_max_buffer", + "DERIVED_ENGINEERING_FEATURE", + "structural_asymmetry", + ["clock_path_max_buffer", "clock_path_min_buffer"], + ), + "F_RT_I_ROUTE": _feature( + "route_wirelength / place_grwl", + "DERIVED_ENGINEERING_FEATURE", + "bound_proximity", + ["route_wirelength", "place_grwl"], + ), + "F_RT_I_TOTAL": _feature( + "route_wirelength / place_hpwl", + "DERIVED_ENGINEERING_FEATURE", + "bound_proximity", + ["route_wirelength", "place_hpwl"], + ), + "F_RT_VIA_DENSITY": _feature( + "route_via_count / route_wirelength", + "DERIVED_ENGINEERING_FEATURE", + "manufacturing_complexity_proxy", + ["route_via_count", "route_wirelength"], + ), + "F_RCX_CPL_FRAC": _feature( + "rcx_worst_coupling_capacitance_ff / rcx_worst_total_capacitance_ff", + "DERIVED_ENGINEERING_FEATURE", + "crosstalk_susceptibility", + ["rcx_worst_coupling_capacitance_ff", "rcx_worst_total_capacitance_ff"], + ), + "F_STA_HEADROOM": _feature( + "sta_setup_ws_ns / Tclk", + "DERIVED_ENGINEERING_FEATURE", + "normalized_headroom", + ["sta_setup_wns"], + ), + "F_STA_FREQ_MARGIN": _feature( + "(1000 / (Tclk - sta_setup_ws_ns) - Ftarget) / Ftarget", + "DERIVED_ENGINEERING_FEATURE", + "frequency_margin", + ["sta_setup_wns"], + ), + "F_STA_PVT_SETUP_DISP": _feature( + "(max_c WSsetup_c - min_c WSsetup_c) / Tclk", + "EMPIRICAL_STATISTICAL_FEATURE", + "corner_dispersion", + ["sta_setup_wns"], + ), + "F_STA_PVT_HOLD_DISP": _feature( + "(max_c WShold_c - min_c WShold_c) / Tclk", + "EMPIRICAL_STATISTICAL_FEATURE", + "corner_dispersion", + ["sta_hold_wns"], + ), + "F_STA_PVT_MAX_DISP": _feature( + "max(delta_setup, delta_hold) / Tclk", + "EMPIRICAL_STATISTICAL_FEATURE", + "corner_dispersion", + ["sta_setup_wns", "sta_hold_wns"], + ), + "S_CONG": _feature( + "max(RUDYmax/tau_rudy, EGRmax/tau_egr_max, EGRtotal/tau_egr_total)", + "CALIBRATED_HEURISTIC", + "congestion_severity_index", + [ + "place_rudy_utilization_max", + "place_congestion_egr_overflow_max", + "place_congestion_egr_overflow_total", + ], + ), +} + + +def make_record(feature_id, value, state, artifacts, interpretation="", compatibility=None): + """Build a FeatureRecord from the registry entry.""" + from chipcompiler.analysis.qor.models import FeatureRecord + + entry = FEATURE_REGISTRY[feature_id] + return FeatureRecord( + feature_id=feature_id, + value=value, + unit="ratio", + formula=entry["formula"], + classification=entry["classification"], + semantic_class=entry["semantic_class"], + state=state, + input_metric_ids=list(entry["input_metric_ids"]), + input_source_artifacts=artifacts, + interpretation=interpretation, + compatibility=compatibility, + ) diff --git a/chipcompiler/analysis/qor/features.py b/chipcompiler/analysis/qor/features.py new file mode 100644 index 000000000..ad54004aa --- /dev/null +++ b/chipcompiler/analysis/qor/features.py @@ -0,0 +1,321 @@ +"""Level-2/Level-3 derived feature computation (spec §6). + +Every division demands an explicit finite, positive denominator; when a +denominator is zero, missing, or non-finite the feature evaluates to +None with state UNKNOWN — epsilon padding is prohibited because it +destroys exact algebraic identities. Cross-stage route ratios stay +UNKNOWN unless net compatibility is EXACT (D-C ladder). +""" + +import dataclasses + +from chipcompiler.analysis.qor import feature_registry +from chipcompiler.analysis.qor.calibration import ( + ROBUSTNESS_CONTRIBUTOR_WATCH, + TAU_EGR_MAX_FAIL, + TAU_EGR_TOTAL_FAIL, + TAU_I_FAIL, + TAU_I_PREF, + TAU_RUDY_FAIL, +) +from chipcompiler.analysis.qor.compatibility import EXACT_COMPATIBLE, Compatibility +from chipcompiler.analysis.qor.models import SourceArtifact + + +@dataclasses.dataclass +class FeatureBundle: + features: dict # feature id -> FeatureRecord + # Raw scalars reused by dimension/evaluation layers. + i_place: float | None = None + i_route: float | None = None + i_total: float | None = None + congestion_severity: float | None = None + cts_imbalance: float | None = None + pvt_setup_disp: float | None = None + pvt_hold_disp: float | None = None + compatibility: "Compatibility | None" = None + + def records(self) -> list: + return list(self.features.values()) + + +def _ratio(numerator, denominator): + if numerator is None or denominator is None: + return None + if denominator <= 0: + return None + return numerator / denominator + + +def _artifacts(inputs, metric_ids): + artifacts = [] + for metric_id in metric_ids: + record = inputs.metrics.get(metric_id) + if record is None: + continue + artifacts.append( + SourceArtifact( + metric=metric_id, + path=inputs.source_path(metric_id), + selector=f"/metrics[id={metric_id}]/value", + ) + ) + return artifacts + + +def _record( + bundle_features, inputs, feature_id, value, state, interpretation="", compatibility=None +): + bundle_features[feature_id] = feature_registry.make_record( + feature_id, + value, + state, + _artifacts(inputs, feature_registry.FEATURE_REGISTRY[feature_id]["input_metric_ids"]), + interpretation=interpretation, + compatibility=compatibility, + ) + + +def _inflation_state(value): + if value is None: + return "UNKNOWN" + if value <= TAU_I_PREF: + return "PASS" + if value < TAU_I_FAIL: + return "WATCH" + return "FAIL" + + +def compute_features(inputs) -> FeatureBundle: + from chipcompiler.analysis.qor.compatibility import evaluate_route_compatibility + + f: dict = {} + + leak = inputs.value("synthesis_power_leakage_uw") + dynamic = inputs.value("synthesis_power_dynamic_uw") + leak_frac = _ratio(leak, (dynamic + leak) if dynamic is not None and dynamic > 0 else None) + _record( + f, + inputs, + "F_SYN_LEAK_FRAC", + leak_frac, + "PASS" if leak_frac is not None else "UNKNOWN", + "Static leakage share of synthesized netlist power.", + ) + + cell_area = inputs.value("synthesis_cell_area") + core_area = inputs.value("core_area") + plan_density = _ratio(cell_area, core_area) + _record( + f, + inputs, + "F_PLAN_DENSITY", + plan_density, + "PASS" if plan_density is not None else "UNKNOWN", + "Early floorplanning feasibility indicator; not placement utilization.", + ) + + i_place = _ratio(inputs.value("place_grwl"), inputs.value("place_hpwl")) + _record( + f, + inputs, + "F_PL_I_PLACE", + i_place, + _inflation_state(i_place), + "Global-routing realization overhead relative to the HPWL baseline.", + ) + + cong_total = inputs.value("place_congestion_egr_overflow_total") + cong_conc = _ratio(inputs.value("place_congestion_egr_overflow_max"), cong_total) + _record( + f, + inputs, + "F_PL_CONG_CONC", + cong_conc, + "PASS" if cong_conc is not None else "UNKNOWN", + "Spatial congestion localization (peak bin share of total overflow).", + ) + + b_max = inputs.value("clock_path_max_buffer") + b_min = inputs.value("clock_path_min_buffer") + imbalance = None + if b_max is not None and b_max >= 1 and b_min is not None: + imbalance = (b_max - b_min) / b_max + _record( + f, + inputs, + "F_CTS_BUF_IMBAL", + imbalance, + ( + "PASS" + if imbalance is not None and imbalance < ROBUSTNESS_CONTRIBUTOR_WATCH + else "WATCH" + if imbalance is not None + else "UNKNOWN" + ), + "Structural clock sink path depth asymmetry; hold risk proxy.", + ) + + compatibility = evaluate_route_compatibility(cts_transformed=_cts_transformed(inputs)) + route_wl = inputs.value("route_wirelength") + i_route = None + i_total = None + route_note = "" + if compatibility.status == EXACT_COMPATIBLE: + i_route = _ratio(route_wl, inputs.value("place_grwl")) + i_total = _ratio(route_wl, inputs.value("place_hpwl")) + else: + route_note = "Route side INCOMPATIBLE; ratios evaluate strictly to UNKNOWN." + + _record( + f, + inputs, + "F_RT_I_ROUTE", + i_route, + _inflation_state(i_route) if route_wl is not None else "UNKNOWN", + "Detailed-routing inflation relative to global routing." + route_note, + compatibility=compatibility.to_contract(), + ) + _record( + f, + inputs, + "F_RT_I_TOTAL", + i_total, + _inflation_state(i_total) if route_wl is not None else "UNKNOWN", + "Total realized interconnect inflation over the HPWL baseline." + route_note, + compatibility=compatibility.to_contract(), + ) + + via_density = _ratio(inputs.value("route_via_count"), route_wl) + _record( + f, + inputs, + "F_RT_VIA_DENSITY", + via_density, + "PASS" if via_density is not None else "UNKNOWN", + "Cut via count per routed micron; manufacturing complexity proxy.", + ) + + coupling = _ratio( + inputs.value("rcx_worst_coupling_capacitance_ff"), + inputs.value("rcx_worst_total_capacitance_ff"), + ) + _record( + f, + inputs, + "F_RCX_CPL_FRAC", + coupling, + "PASS" if coupling is not None else "UNKNOWN", + "Parasitic lateral coupling share; crosstalk susceptibility indicator.", + ) + + congestion = None + severity_terms = [] + rudy = inputs.value("place_rudy_utilization_max") + lut_rudy = inputs.value("place_lutrudy_utilization_max") + egr_max = inputs.value("place_congestion_egr_overflow_max") + egr_total = inputs.value("place_congestion_egr_overflow_total") + if rudy is not None: + severity_terms.append(rudy / TAU_RUDY_FAIL) + if lut_rudy is not None: + severity_terms.append(lut_rudy / TAU_RUDY_FAIL) + if egr_max is not None: + severity_terms.append(egr_max / TAU_EGR_MAX_FAIL) + if egr_total is not None: + severity_terms.append(egr_total / TAU_EGR_TOTAL_FAIL) + if severity_terms: + congestion = max(severity_terms) + + tclk = inputs.tclk_ns + ws = inputs.value("sta_setup_wns") + headroom = _ratio(ws, tclk) if tclk else None + _record( + f, + inputs, + "F_STA_HEADROOM", + headroom, + _headroom_state(ws, tclk), + "Normalized signed setup slack headroom.", + ) + + setup_ws_values = [corner.setup_ws for corner in inputs.corners] + hold_ws_values = [corner.hold_ws for corner in inputs.corners] + delta_setup = max(setup_ws_values) - min(setup_ws_values) if len(setup_ws_values) >= 2 else None + delta_hold = max(hold_ws_values) - min(hold_ws_values) if len(hold_ws_values) >= 2 else None + pvt_setup = _ratio(delta_setup, tclk) if tclk else None + pvt_hold = _ratio(delta_hold, tclk) if tclk else None + pvt_max: float | None = None + if pvt_setup is not None and pvt_hold is not None: + pvt_max = max(pvt_setup, pvt_hold) + _record( + f, + inputs, + "F_STA_PVT_SETUP_DISP", + pvt_setup, + "PASS" if pvt_setup is not None else "UNKNOWN", + "Setup timing sensitivity to multi-corner PVT variations.", + ) + _record( + f, + inputs, + "F_STA_PVT_HOLD_DISP", + pvt_hold, + "PASS" if pvt_hold is not None else "UNKNOWN", + "Hold timing sensitivity to multi-corner PVT variations.", + ) + _record( + f, + inputs, + "F_STA_PVT_MAX_DISP", + pvt_max, + "PASS" if pvt_max is not None else "UNKNOWN", + "Maximum combined timing sensitivity across PVT corners.", + ) + + return FeatureBundle( + features=f, + i_place=i_place, + i_route=i_route, + i_total=i_total, + congestion_severity=congestion, + cts_imbalance=imbalance, + pvt_setup_disp=pvt_setup, + pvt_hold_disp=pvt_hold, + compatibility=compatibility, + ) + + +def _headroom_state(ws, tclk): + if ws is None or not tclk: + return "UNKNOWN" + from chipcompiler.analysis.qor.calibration import ( + GUARDBAND_FRACTION, + OVER_PROVISION_FRACTION, + ) + + guardband = GUARDBAND_FRACTION * tclk + over_provision = OVER_PROVISION_FRACTION * tclk + if ws < 0: + return "FAIL" + if ws < guardband: + return "WATCH" + if ws > over_provision: + return "OPPORTUNITY" + return "PASS" + + +def _cts_transformed(inputs) -> bool: + """True when CTS ran and its buffer population is present or unknown. + + A completed CTS step inserts clock-tree nets; only an explicitly + measured zero buffer/inverter population (CTS step succeeded with a + counted empty insertion, e.g. no clock or direct conversion) keeps + the identity mapping. + """ + if inputs.step_state("CTS") != "Success": + return False + buffers = inputs.value("cts_buffer_count") + inverters = inputs.value("cts_inverter_count") + if buffers is None or inverters is None: + return True + return buffers + inverters > 0 diff --git a/chipcompiler/analysis/qor/interventions.py b/chipcompiler/analysis/qor/interventions.py new file mode 100644 index 000000000..aa6ef0311 --- /dev/null +++ b/chipcompiler/analysis/qor/interventions.py @@ -0,0 +1,47 @@ +"""Deterministic lexicographic intervention prioritization (spec §11.4). + +Interventions are hypotheses, never promises: every record names its +validation procedure and diagnosis confidence is kept separate from +intervention confidence (C_diagnosis != C_intervention). +""" + +from chipcompiler.analysis.qor.models import Intervention + +TIER_1 = "TIER_1_FEASIBILITY" +TIER_2 = "TIER_2_BOTTLENECK" +TIER_3 = "TIER_3_OPPORTUNITY" + +_TIER_ORDER = {TIER_1: 0, TIER_2: 1, TIER_3: 2} + + +def make(hypothesis, tier, confidence="LOW", parameter_knob=None, validation_procedure=None): + return Intervention( + hypothesis=hypothesis, + tier=tier, + confidence=confidence, + parameter_knob=parameter_knob, + validation_procedure=validation_procedure, + ) + + +def prioritize(diagnoses) -> list: + """Flatten diagnosis interventions under the strict three-tier policy. + + Tier 1 (feasibility blockers) precedes Tier 2 (quality limiters), + which precedes Tier 3 (optimization opportunities); within a tier, + the parent diagnosis severity decides, then diagnosis id for a total + deterministic order. + """ + decorated = [] + for diagnosis in diagnoses: + for intervention in diagnosis.interventions: + decorated.append( + ( + _TIER_ORDER[intervention.tier], + -diagnosis.severity, + diagnosis.diagnosis_id, + intervention, + ) + ) + decorated.sort(key=lambda item: item[:3]) + return [item[3] for item in decorated] diff --git a/chipcompiler/analysis/qor/loader.py b/chipcompiler/analysis/qor/loader.py new file mode 100644 index 000000000..f1d52a85d --- /dev/null +++ b/chipcompiler/analysis/qor/loader.py @@ -0,0 +1,255 @@ +"""Workspace reader for the QoR engine. + +Reads only persisted artifacts; it never mutates workspace state and +never treats a missing report as zero. Steps count toward analysis only +when their ledger state is Success — invalidation keeps old outputs on +disk, and stale suffixes must not report obsolete metrics as current. +""" + +import dataclasses +from pathlib import Path + +from chipcompiler.analysis.qor.metric_registry import SCORED_STEP_VALUES +from chipcompiler.data import StateEnum, StepEnum +from chipcompiler.data.step_dirs import STEP_DIRECTORIES +from chipcompiler.tools.ecc.sta_qor import ( + STA_POWER_SUMMARY_FILENAME, + read_sta_power_summary_json, + read_sta_qor_summary, +) +from chipcompiler.utility.json import json_read + +_ROLE_PRIORITY = {"final": 0, "gate": 1, "trend": 2, "none": 3} + +_STA_FEATURE_DIR = STEP_DIRECTORIES[StepEnum.STA.value] + "/feature" + + +@dataclasses.dataclass +class MetricRecord: + metric_id: str + value: float + unit: str + step: str # step enum value + project_role: str + corner: str | None + source: dict + + +@dataclasses.dataclass(frozen=True) +class CornerSlack: + corner: str + setup_ws: float + hold_ws: float + setup_nvp: int + hold_nvp: int + + +@dataclasses.dataclass +class QorInputs: + design: str = "" + workspace_path: str = "" + flow_states: dict = dataclasses.field(default_factory=dict) + metrics: dict = dataclasses.field(default_factory=dict) # metric id -> MetricRecord + analyzed_steps: list = dataclasses.field(default_factory=list) + parse_failures: int = 0 + invalid_selector_count: int = 0 + corners: list = dataclasses.field(default_factory=list) # CornerSlack records + sta_expected_corners: float | None = None + rcx_spef_count: float | None = None + rcx_expected_spef: float | None = None + power_total_uw: float | None = None + tclk_ns: float | None = None + profile: str = "balanced" + power_budget_uw: float | None = None + config_warnings: list = dataclasses.field(default_factory=list) + + def value(self, metric_id): + record = self.metrics.get(metric_id) + return record.value if record is not None else None + + def source_path(self, metric_id): + record = self.metrics.get(metric_id) + if record is None or not isinstance(record.source, dict): + return "" + return str(record.source.get("path", "")) + + def step_state(self, step_value): + return self.flow_states.get(step_value) + + +def _flow_states(workspace, workspace_root: Path) -> dict: + flow = getattr(workspace, "flow", None) + data = getattr(flow, "data", None) + if not isinstance(data, dict) or not data: + # load_workspace leaves flow.data empty; the persisted file is the + # source of truth (same fallback the signoff collector uses). + data = json_read(workspace_root / "home" / "flow.json") + steps = data.get("steps") if isinstance(data, dict) else None + states = {} + if isinstance(steps, list): + for raw in steps: + if isinstance(raw, dict) and isinstance(raw.get("name"), str): + state = raw.get("state") + states[raw["name"]] = state if isinstance(state, str) else "" + return states + + +def _parameters(workspace, workspace_root: Path) -> dict: + parameters = getattr(getattr(workspace, "parameters", None), "data", None) + if isinstance(parameters, dict): + return parameters + legacy = json_read(workspace_root / "home" / "parameters.json") + return legacy if isinstance(legacy, dict) else {} + + +def _payload_metrics(payload: dict) -> list: + if not isinstance(payload, dict): + return [] + if payload.get("schema_version") != 3 or not isinstance(payload.get("metrics"), list): + return [] + return [raw for raw in payload["metrics"] if isinstance(raw, dict)] + + +def _select_metrics(step_payloads: list) -> dict: + """Project-level selection: final > gate > trend, later step wins.""" + selected: dict = {} + for index, (step_value, records) in enumerate(step_payloads): + for raw in records: + metric_id = raw.get("id") + value = raw.get("value") + if not isinstance(metric_id, str) or not metric_id: + continue + if isinstance(value, bool) or not isinstance(value, (int, float)): + continue + role = raw.get("project_role") + if role not in _ROLE_PRIORITY or role == "none": + continue + record = MetricRecord( + metric_id=metric_id, + value=float(value), + unit=raw.get("unit") if isinstance(raw.get("unit"), str) else "", + step=step_value, + project_role=role, + corner=raw.get("corner") if isinstance(raw.get("corner"), str) else None, + source=raw.get("source") if isinstance(raw.get("source"), dict) else {}, + ) + rank = (_ROLE_PRIORITY[role], -index) + current = selected.get(metric_id) + if current is None or rank < current[0]: + selected[metric_id] = (rank, record) + return {metric_id: rank_record[1] for metric_id, rank_record in selected.items()} + + +def _corner_slack(workspace_root: Path) -> list: + corners = [] + feature_root = workspace_root / _STA_FEATURE_DIR + if not feature_root.is_dir(): + return corners + for path in sorted(feature_root.glob("*/*/qor_summary.json")): + summary = read_sta_qor_summary(path.parts[-3], path) + if summary is None: + continue + corners.append( + CornerSlack( + corner=summary.corner, + setup_ws=summary.setup_wns, + hold_ws=summary.hold_wns, + setup_nvp=summary.setup_nvp, + hold_nvp=summary.hold_nvp, + ) + ) + return corners + + +def _resolve_parameters(parameters: dict, warnings: list) -> tuple: + """Resolve (tclk_ns, profile, power_budget_uw) from flat parameters.""" + tclk_ns = None + frequency_max = parameters.get("frequency_max") + if isinstance(frequency_max, (int, float)) and not isinstance(frequency_max, bool): + if frequency_max > 0: + tclk_ns = 1000.0 / float(frequency_max) + else: + warnings.append("frequency_max must be positive; timing quality is UNKNOWN") + + profile = parameters.get("qor_profile") + if profile is None or profile == "": + profile = "balanced" + elif profile not in ("balanced", "timing_critical", "low_power", "area_optimized"): + warnings.append(f"unknown qor_profile {profile!r}; using 'balanced'") + profile = "balanced" + + budget = parameters.get("qor_power_budget_w") + power_budget_uw = None + if budget is not None: + if isinstance(budget, (int, float)) and not isinstance(budget, bool) and budget > 0: + power_budget_uw = float(budget) * 1e6 + else: + warnings.append("qor_power_budget_w must be a positive number; power quality UNKNOWN") + return tclk_ns, profile, power_budget_uw + + +def load_workspace_qor_inputs(workspace) -> QorInputs: + workspace_root = Path(workspace.directory or "") + design = getattr(getattr(workspace, "design", None), "name", "") or getattr( + workspace, "name", "" + ) + if not design: + parameters = _parameters(workspace, workspace_root) + design = parameters.get("design") or parameters.get("Design") or "" + + warnings: list = [] + parameters = _parameters(workspace, workspace_root) + tclk_ns, profile, power_budget_uw = _resolve_parameters(parameters, warnings) + flow_states = _flow_states(workspace, workspace_root) + + step_payloads: list = [] + analyzed_steps: list = [] + parse_failures = 0 + invalid_selector_count = 0 + for step_value in SCORED_STEP_VALUES: + if flow_states.get(step_value) != StateEnum.Success.value: + continue + directory = STEP_DIRECTORIES[step_value] + payload = json_read(workspace_root / directory / "analysis" / "qor_metrics.json") + records = _payload_metrics(payload) + if payload is None or not records: + parse_failures += 1 + continue + analyzed_steps.append(step_value) + integrity = payload.get("integrity") + if isinstance(integrity, dict): + invalid_selector_count += len(integrity.get("invalid_metric_source_ids") or []) + invalid_selector_count += len(integrity.get("invalid_detail_ids") or []) + step_payloads.append((step_value, records)) + + metrics = _select_metrics(step_payloads) + + def _metric_value(metric_id: str) -> float | None: + record = metrics.get(metric_id) + return record.value if record is not None else None + + power_total_uw = None + power_summary = read_sta_power_summary_json( + workspace_root / _STA_FEATURE_DIR / STA_POWER_SUMMARY_FILENAME + ) + if power_summary is not None: + power_total_uw = power_summary.dynamic_uw + power_summary.leakage_uw + + return QorInputs( + design=design, + workspace_path=str(workspace_root), + flow_states=flow_states, + metrics=metrics, + analyzed_steps=analyzed_steps, + parse_failures=parse_failures, + invalid_selector_count=invalid_selector_count, + corners=_corner_slack(workspace_root), + sta_expected_corners=_metric_value("sta_expected_corner_count"), + rcx_spef_count=_metric_value("rcx_spef_file_count"), + rcx_expected_spef=_metric_value("rcx_expected_corner_count"), + power_total_uw=power_total_uw, + tclk_ns=tclk_ns, + profile=profile, + power_budget_uw=power_budget_uw, + config_warnings=warnings, + ) diff --git a/chipcompiler/analysis/qor/metric_registry.py b/chipcompiler/analysis/qor/metric_registry.py new file mode 100644 index 000000000..8ac20e40c --- /dev/null +++ b/chipcompiler/analysis/qor/metric_registry.py @@ -0,0 +1,81 @@ +"""Canonical registry of metric ids consumed by the QoR engine. + +The step emitters (``tools/ecc/metrics.py``) own extraction and keep +emitting schema-v3 ``qor_metrics.json`` unchanged; this registry is the +single place that declares which canonical ids the engine reads, their +units, and their polarities. + +Naming note (spec §2.3): the legacy emitter id ``sta_setup_wns`` carries +the *signed worst setup slack* across corners (never clamped). The +engine exposes it as signed ``WS`` and derives the clamped ``WNS = +min(0, WS)`` for gating; the legacy id stays the wire name for +compatibility with already-published artifacts. +""" + +# metric id -> (unit, polarity) +METRIC_REGISTRY = { + "synthesis_cell_area": ("um^2", "lower_is_better"), + "synthesis_cell_count": ("count", "trend_only"), + "synthesis_wire_count": ("count", "trend_only"), + "synthesis_power_dynamic_uw": ("uW", "lower_is_better"), + "synthesis_power_leakage_uw": ("uW", "lower_is_better"), + "die_area": ("um^2", "lower_is_better"), + "core_area": ("um^2", "lower_is_better"), + "core_utilization": ("ratio", "target_range"), + "place_hpwl": ("um", "lower_is_better"), + "place_grwl": ("um", "lower_is_better"), + "place_flute_wirelength": ("um", "lower_is_better"), + "place_congestion_egr_overflow_max": ("count", "lower_is_better"), + "place_congestion_egr_overflow_total": ("count", "lower_is_better"), + "place_rudy_utilization_max": ("ratio", "lower_is_better"), + "place_lutrudy_utilization_max": ("ratio", "lower_is_better"), + "cts_buffer_count": ("count", "lower_is_better"), + "cts_inverter_count": ("count", "lower_is_better"), + "clock_path_max_buffer": ("count", "lower_is_better"), + "clock_path_min_buffer": ("count", "trend_only"), + "clock_wirelength": ("um", "lower_is_better"), + "route_wirelength": ("um", "lower_is_better"), + "route_via_count": ("count", "lower_is_better"), + "rcx_spef_file_count": ("count", "trend_only"), + "rcx_expected_corner_count": ("count", "trend_only"), + "rcx_missing_corner_count": ("count", "lower_is_better"), + "rcx_spef_parse_failure_count": ("count", "lower_is_better"), + "rcx_worst_total_capacitance_ff": ("fF", "lower_is_better"), + "rcx_worst_coupling_capacitance_ff": ("fF", "lower_is_better"), + "drc_count": ("count", "lower_is_better"), + "lvs_count": ("count", "lower_is_better"), + "harden_artifact_missing_count": ("count", "lower_is_better"), + "sta_setup_wns": ("ns", "higher_is_better"), + "sta_setup_tns": ("ns", "higher_is_better"), + "sta_hold_wns": ("ns", "higher_is_better"), + "sta_hold_tns": ("ns", "higher_is_better"), + "sta_frequency_mhz": ("MHz", "higher_is_better"), + "sta_setup_violation_count": ("count", "lower_is_better"), + "sta_hold_violation_count": ("count", "lower_is_better"), + "sta_corner_count": ("count", "trend_only"), + "sta_expected_corner_count": ("count", "trend_only"), + "sta_missing_corner_count": ("count", "lower_is_better"), + "sta_worst_setup_corner": ("", "trend_only"), +} + +# Steps whose Success state and analysis payload the engine consumes. +SCORED_STEP_VALUES = ( + "Synthesis", + "Floorplan", + "place", + "CTS", + "legalization", + "route", + "drc", + "lvs", + "RCX", + "sta", + "Harden", +) + + +def clamped_wns(ws_ns): + """WNS = min(0, WS); gating-only, never a continuous quality input.""" + if ws_ns is None: + return None + return min(0.0, ws_ns) diff --git a/chipcompiler/analysis/qor/models.py b/chipcompiler/analysis/qor/models.py new file mode 100644 index 000000000..15d90bcff --- /dev/null +++ b/chipcompiler/analysis/qor/models.py @@ -0,0 +1,179 @@ +"""Typed records for the ECC-QoR draft 3 analysis report. + +Physical design quality (``Qphys``), physical signoff feasibility, and +measurement evidence completeness are three distinct semantic domains; +their records are never merged. Every score coordinate is ``[0, 100] or +None`` — a dimension that cannot be evaluated is ``None`` with an +explicit ``UNKNOWN`` state, never zero. +""" + +import dataclasses + +SCHEMA_VERSION = 3 +SCORING_ENGINE = "qor-v3" + + +def to_dict(value): + """Convert nested dataclasses/lists/dicts into JSON-safe structures.""" + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return {f.name: to_dict(getattr(value, f.name)) for f in dataclasses.fields(value)} + if isinstance(value, (list, tuple)): + return [to_dict(item) for item in value] + if isinstance(value, dict): + return {str(key): to_dict(item) for key, item in value.items()} + return value + + +@dataclasses.dataclass(frozen=True) +class SourceArtifact: + metric: str + path: str + selector: str + + +@dataclasses.dataclass(frozen=True) +class FeatureRecord: + feature_id: str + value: float | None + unit: str + formula: str + classification: str + semantic_class: str + state: str + input_metric_ids: list + input_source_artifacts: list + interpretation: str = "" + compatibility: dict | None = None + + +@dataclasses.dataclass(frozen=True) +class QorDimension: + key: str + value: float | None + state: str + features: list + + +@dataclasses.dataclass(frozen=True) +class SlackView: + ws_ns: float | None + wns_ns: float | None + tns_ns: float | None = None + nvp: int | None = None + worst_corner: str | None = None + + +@dataclasses.dataclass(frozen=True) +class FeasibilityGate: + id: str + stage: str + state: str # passed | failed | unavailable + predicate: str + blocks_tapeout: bool + metrics: list + # Why an unavailable gate could not be evaluated: the stage never + # completed ("not_verified") or it completed with missing/corrupt + # evidence ("corrupt"). None when state is not "unavailable". + availability: str | None = None + timing_slack: SlackView | None = None + + +@dataclasses.dataclass(frozen=True) +class Feasibility: + status: str # PASS | PHYSICAL_FAIL | NOT_VERIFIED | UNKNOWN + gates: list + + +@dataclasses.dataclass(frozen=True) +class Evidence: + index: float | None + state: str # HIGH | MODERATE | LIMITED | INSUFFICIENT | NOT_VERIFIED + integrity: float | None + coverage: float | None + consistency: float | None + + +@dataclasses.dataclass(frozen=True) +class Intervention: + hypothesis: str + tier: str # TIER_1_FEASIBILITY | TIER_2_BOTTLENECK | TIER_3_OPPORTUNITY + confidence: str # HIGH | MEDIUM | LOW + parameter_knob: str | None = None + validation_procedure: str | None = None + + +@dataclasses.dataclass(frozen=True) +class SupportingMetric: + name: str + value: object # number or string + unit: str + source: str + + +@dataclasses.dataclass(frozen=True) +class Diagnosis: + diagnosis_id: str + state: str + severity: float + diagnosis_confidence: str + trigger_features: list + supporting_metrics: list + interpretation: str + affected_dimensions: list + interventions: list + intervention_confidence: str + validation_required: str | None = None + + +@dataclasses.dataclass(frozen=True) +class ScalarSummary: + score: float | None + status: str # GREEN | YELLOW | ORANGE | RED | FAIL | NOT_RATED + profile: str + weights: dict + + +@dataclasses.dataclass(frozen=True) +class InflationView: + """Tri-partite interconnect decomposition under D-C degradation. + + ``i_route``/``i_total`` require place→route net compatibility; while + the toolchain emits no net mapping they stay UNKNOWN and QI falls + back to ``i_place`` (same-netlist, EXACT) with downgraded evidence. + """ + + i_place: float | None + i_route: float | None + i_total: float | None + congestion_severity: float | None + compatibility_status: str + + +@dataclasses.dataclass(frozen=True) +class QorAnalysis: + schema_version: int + scoring_engine: str + design: str + workspace: str + timestamp: str + profile: str + tclk_ns: float | None + feasibility: Feasibility + evidence: Evidence + qor_record: dict # dimension key -> QorDimension + scalar_summary: ScalarSummary + diagnoses: list + inflation: InflationView + flow_steps: dict # step value -> persisted state snapshot + config_warnings: list + + def to_dict(self) -> dict: + return to_dict(self) + + @property + def overall_score(self) -> float | None: + return self.scalar_summary.score + + @property + def dimension_scores(self) -> list: + return sorted(self.qor_record.values(), key=lambda dim: dim.key) diff --git a/chipcompiler/analysis/qor/renderer.py b/chipcompiler/analysis/qor/renderer.py new file mode 100644 index 000000000..2f3dafbfa --- /dev/null +++ b/chipcompiler/analysis/qor/renderer.py @@ -0,0 +1,188 @@ +"""ASCII CLI renderer for the QoR analysis report (spec §12.1 layout). + +The renderer takes the assembled analysis plus the loader inputs (for +the raw engineering numbers in the breakdown notes); passing only the +analysis renders the same layout without detail notes. +""" + +WIDTH = 78 + + +def _fmt(value, digits=1): + return "—" if value is None else f"{value:.{digits}f}" + + +def _fmt_signed(value, unit="ns"): + if value is None: + return "—" + if value == 0: + return f"0{unit}" + return f"{value:+g}{unit}" if unit else f"{value:+g}" + + +def _pct(value): + return None if value is None else value * 100.0 + + +def render(analysis, inputs=None) -> str: + feasibility = analysis.feasibility + evidence = analysis.evidence + summary = analysis.scalar_summary + record = analysis.qor_record + + lines: list = [] + lines.append("=" * WIDTH) + lines.append(f" ECC QoR ANALYSIS REPORT - Design: {analysis.design or ''}") + lines.append(f" Workspace: {analysis.workspace}") + lines.append("=" * WIDTH) + + lines.append(f" FEASIBILITY STATUS : {_feasibility_line(feasibility)}") + + evidence_detail = ( + f"Integrity: {_fmt(_pct(evidence.integrity))}%, " + f"Coverage: {_fmt(_pct(evidence.coverage))}%, " + f"Consistency: {_fmt(_pct(evidence.consistency))}%" + ) + lines.append(f" EVIDENCE STATE : {evidence.state} [{evidence_detail}]") + + lines.append( + f" QoR COMPOSITE : {_fmt(summary.score)} / 100 " + f"(Status: {summary.status}, Profile: {summary.profile})" + ) + for warning in analysis.config_warnings: + lines.append(f" CONFIG WARNING : {warning}") + lines.append("-" * WIDTH) + + lines.append(" [PHYSICAL QoR RECORD BREAKDOWN]") + for label, key, note in ( + ("Timing Quality (Q_T) ", "timing", _timing_note(analysis)), + ("Interconnect Quality (Q_I)", "interconnect", _interconnect_note(analysis)), + ("Area Efficiency (Q_A) ", "area", _area_note(analysis, inputs)), + ("Power Quality (Q_P) ", "power", _power_note(analysis, inputs)), + ("Robustness (Q_R) ", "robustness", _robustness_note(analysis, inputs)), + ): + dimension = record[key] + lines.append( + f" {label}: {_fmt(dimension.value)} / 100 [{dimension.state}] {note}".rstrip() + ) + lines.append("-" * WIDTH) + + blockers = [d for d in analysis.diagnoses if d.state == "FAIL"] + watches = [d for d in analysis.diagnoses if d.state != "FAIL"] + + lines.append(" [PRIMARY DIAGNOSES]") + if not blockers: + lines.append(" (No active feasibility blockers detected)") + for diagnosis in blockers: + lines.append( + f" [FAIL] {diagnosis.diagnosis_id} " + f"(Severity: {diagnosis.severity:.2f}, Confidence: {diagnosis.diagnosis_confidence})" + ) + lines.append(f" {diagnosis.interpretation}") + for metric in diagnosis.supporting_metrics: + lines.append(f" --> {metric.name}: {metric.value:g} {metric.unit}".rstrip()) + lines.append("") + lines.append(" [WATCH & OPPORTUNITY DIAGNOSES]") + if not watches: + lines.append(" (None)") + for diagnosis in watches: + label = "OPPORTUNITY" if diagnosis.state == "OPPORTUNITY" else "WATCH" + lines.append( + f" [{label}] {diagnosis.diagnosis_id} " + f"(Severity: {diagnosis.severity:.2f}, Confidence: {diagnosis.diagnosis_confidence})" + ) + lines.append(f" {diagnosis.interpretation}") + lines.append("-" * WIDTH) + + from chipcompiler.analysis.qor.interventions import prioritize + + lines.append(" [PRIORITIZED INTERVENTION HYPOTHESES]") + ranked = prioritize(analysis.diagnoses) + if not ranked: + lines.append(" (None)") + for index, intervention in enumerate(ranked, start=1): + tier_label = intervention.tier.replace("TIER_", "Tier ") + tier_label = tier_label.replace("_FEASIBILITY", " (Feasibility)") + tier_label = tier_label.replace("_BOTTLENECK", " (Quality Limiter)") + tier_label = tier_label.replace("_OPPORTUNITY", " (Opportunity)") + lines.append(f" {index}. [{tier_label}] {intervention.hypothesis}") + lines.append("=" * WIDTH) + return "\n".join(lines) + + +def _feasibility_line(feasibility) -> str: + if feasibility.status == "PASS": + return "PASS [All 7 Physical Signoff Gates Clean]" + failed = [gate.id for gate in feasibility.gates if gate.state == "failed"] + if failed: + return f"PHYSICAL_FAIL [Failed gates: {', '.join(failed)}]" + unverified = [ + f"{gate.id}({gate.availability})" + for gate in feasibility.gates + if gate.state == "unavailable" + ] + return f"{feasibility.status} [Gates awaiting evidence: {', '.join(unverified) or 'none'}]" + + +def _timing_note(analysis): + slack = None + for gate in analysis.feasibility.gates: + if gate.id == "GATE_SETUP_SLACK" and gate.timing_slack is not None: + slack = gate.timing_slack + if slack is None: + return "(WS: —)" + return f"(WS: {_fmt_signed(slack.ws_ns)}, WNS: {_fmt_signed(slack.wns_ns)})" + + +def _interconnect_note(analysis): + inflation = analysis.inflation + if inflation.i_total is not None: + i_text = f"I_total: {_fmt(inflation.i_total, 3)}" + elif inflation.i_place is not None: + i_text = ( + f"I_place: {_fmt(inflation.i_place, 3)} ({inflation.compatibility_status} route side)" + ) + else: + return "(I: —)" + congestion = ( + f"S_cong: {_fmt(inflation.congestion_severity, 2)}" + if inflation.congestion_severity is not None + else "S_cong: —" + ) + return f"({i_text}, {congestion})" + + +def _area_note(analysis, inputs): + if inputs is None: + return "" + utilization = inputs.value("core_utilization") + if utilization is None: + return "" + return f"(Core Util: {utilization * 100:.1f}%)" + + +def _power_note(analysis, inputs): + if analysis.qor_record["power"].value is not None and inputs is not None: + total = inputs.power_total_uw + if total is not None: + return f"(Ptotal: {_fmt(total / 1e6, 3)}W of {_fmt(inputs.power_budget_uw / 1e6, 3)}W)" + return "" + if inputs is not None and inputs.power_budget_uw is None: + return "(No budget declared)" + return "(UNKNOWN)" + + +def _robustness_note(analysis, inputs): + if inputs is None: + return "" + parts = [] + imbalance = None + for feature in analysis.qor_record["robustness"].features: + if feature.feature_id == "F_CTS_BUF_IMBAL": + imbalance = feature.value + if imbalance is not None: + parts.append(f"CTS Imbal: {_fmt(imbalance, 1)}") + if inputs.corners: + spread = max(c.setup_ws for c in inputs.corners) - min(c.setup_ws for c in inputs.corners) + parts.append(f"PVT Spread: {spread:.2f}ns") + return f"({', '.join(parts)})" if parts else "" diff --git a/chipcompiler/analysis/qor/schema.py b/chipcompiler/analysis/qor/schema.py new file mode 100644 index 000000000..722e148b3 --- /dev/null +++ b/chipcompiler/analysis/qor/schema.py @@ -0,0 +1,160 @@ +"""JSON contract for ``home/qor_report.json`` and a structural validator. + +The schema mirrors the emitted subset of the spec's Draft 2020-12 +contract (§13) restricted to what this engine produces. The validator is +a hand-rolled structural check (no external dependency): it enforces +required keys, enums, and numeric ranges so a malformed report fails +loudly in tests instead of silently downstream. +""" + +from chipcompiler.analysis.qor.models import SCHEMA_VERSION + +FEASIBILITY_STATUSES = ("PASS", "PHYSICAL_FAIL", "NOT_VERIFIED", "UNKNOWN") +GATE_STATES = ("passed", "failed", "unavailable") +EVIDENCE_STATES = ("HIGH", "MODERATE", "LIMITED", "INSUFFICIENT", "NOT_VERIFIED") +DIMENSION_STATES = ("PASS", "FAIL", "WATCH", "OVER_PROVISIONED", "OPPORTUNITY", "UNKNOWN") +SCALAR_STATUSES = ("GREEN", "YELLOW", "ORANGE", "RED", "FAIL", "NOT_RATED") +TIERS = ("TIER_1_FEASIBILITY", "TIER_2_BOTTLENECK", "TIER_3_OPPORTUNITY") +CONFIDENCES = ("HIGH", "MEDIUM", "LOW") +DIMENSION_KEYS = ("timing", "interconnect", "area", "power", "robustness") + +SCHEMA = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "EccQorAnalysisReport", + "type": "object", + "required": [ + "schema_version", + "scoring_engine", + "design", + "workspace", + "timestamp", + "profile", + "tclk_ns", + "feasibility", + "evidence", + "qor_record", + "scalar_summary", + "diagnoses", + "inflation", + "flow_steps", + ], + "properties": { + "schema_version": {"const": SCHEMA_VERSION}, + "feasibility": { + "required": ["status", "gates"], + "status_enum": FEASIBILITY_STATUSES, + }, + "evidence": { + "required": ["index", "state", "integrity", "coverage", "consistency"], + "state_enum": EVIDENCE_STATES, + "unit_interval_fields": ["index", "integrity", "coverage", "consistency"], + }, + "qor_record": { + "dimension_keys": DIMENSION_KEYS, + "state_enum": DIMENSION_STATES, + }, + "scalar_summary": { + "required": ["score", "status", "profile", "weights"], + "status_enum": SCALAR_STATUSES, + }, + }, +} + + +def validate_report(report: dict) -> list: + """Return a list of structural violations; empty means valid.""" + errors = [] + + def require(condition, message): + if not condition: + errors.append(message) + + if not isinstance(report, dict): + return ["report is not an object"] + for key in SCHEMA["required"]: + require(key in report, f"missing required key {key!r}") + if errors: + return errors + + require(report["schema_version"] == SCHEMA_VERSION, "schema_version must be 3") + require(report["scoring_engine"] == "qor-v3", "scoring_engine must be qor-v3") + require(isinstance(report["timestamp"], str), "timestamp must be a string") + + feasibility = report["feasibility"] + require(isinstance(feasibility, dict), "feasibility must be an object") + require( + feasibility.get("status") in FEASIBILITY_STATUSES, + f"feasibility.status invalid: {feasibility.get('status')!r}", + ) + require(isinstance(feasibility.get("gates"), list), "feasibility.gates must be a list") + for gate in feasibility["gates"]: + require(isinstance(gate, dict) and gate.get("id"), "gate requires an id") + require(gate.get("state") in GATE_STATES, f"gate state invalid: {gate.get('state')!r}") + require(isinstance(gate.get("blocks_tapeout"), bool), "gate.blocks_tapeout must be bool") + + evidence = report["evidence"] + require( + evidence.get("state") in EVIDENCE_STATES, + f"evidence.state invalid: {evidence.get('state')!r}", + ) + for field in ("index", "integrity", "coverage", "consistency"): + value = evidence.get(field) + limit = 100 if field == "index" else 1 + valid = isinstance(value, (int, float)) and 0 <= value <= limit + require(value is None or valid, f"evidence.{field} out of range") + + qor_record = report["qor_record"] + require(isinstance(qor_record, dict), "qor_record must be an object") + for key in DIMENSION_KEYS: + dimension = qor_record.get(key) + require(isinstance(dimension, dict), f"qor_record.{key} missing") + if isinstance(dimension, dict): + value = dimension.get("value") + require( + value is None or (isinstance(value, (int, float)) and 0 <= value <= 100), + f"qor_record.{key}.value out of range", + ) + require( + dimension.get("state") in DIMENSION_STATES, + f"qor_record.{key}.state invalid", + ) + require( + isinstance(dimension.get("features"), list), + "dimension.features must be a list", + ) + + summary = report["scalar_summary"] + require( + summary.get("status") in SCALAR_STATUSES, + f"scalar_summary.status invalid: {summary.get('status')!r}", + ) + score = summary.get("score") + require( + score is None or (isinstance(score, (int, float)) and 0 <= score <= 100), + "scalar_summary.score out of range", + ) + require(isinstance(summary.get("weights"), dict), "scalar_summary.weights must be an object") + + require(isinstance(report["diagnoses"], list), "diagnoses must be a list") + for diagnosis in report["diagnoses"]: + require( + isinstance(diagnosis, dict) and diagnosis.get("diagnosis_id"), + "diagnosis requires id", + ) + severity = diagnosis.get("severity") + require( + isinstance(severity, (int, float)) and 0 <= severity <= 1, + f"diagnosis severity out of range: {severity!r}", + ) + require( + diagnosis.get("diagnosis_confidence") in CONFIDENCES, + "diagnosis_confidence invalid", + ) + for intervention in diagnosis.get("interventions") or []: + require(intervention.get("tier") in TIERS, "intervention tier invalid") + require( + intervention.get("confidence") in CONFIDENCES, + "intervention confidence invalid", + ) + + return errors diff --git a/chipcompiler/analysis/qor/scoring.py b/chipcompiler/analysis/qor/scoring.py new file mode 100644 index 000000000..dd1ba9df1 --- /dev/null +++ b/chipcompiler/analysis/qor/scoring.py @@ -0,0 +1,83 @@ +"""Scalar summary projection and design-intent profiles (spec §9). + +Qsummary is a profile-dependent display projection, never an objective +physical truth. The feasibility veto is absolute: a physical signoff +failure forces score 0.0 so that excellent area or power can never mask +an unmanufacturable chip; unevaluated evidence yields NOT_RATED instead +of a fabricated number. +""" + +from chipcompiler.analysis.qor.models import ScalarSummary + +_BALANCED = "balanced" +_TIMING_CRITICAL = "timing_critical" +_LOW_POWER = "low_power" +_AREA_OPTIMIZED = "area_optimized" + +# Dimension weights per design-intent profile (spec Table 5). +PROFILES = { + _BALANCED: { + "timing": 0.30, + "interconnect": 0.25, + "area": 0.15, + "power": 0.15, + "robustness": 0.15, + }, + _TIMING_CRITICAL: { + "timing": 0.45, + "interconnect": 0.20, + "area": 0.10, + "power": 0.10, + "robustness": 0.15, + }, + _LOW_POWER: { + "timing": 0.20, + "interconnect": 0.15, + "area": 0.15, + "power": 0.35, + "robustness": 0.15, + }, + _AREA_OPTIMIZED: { + "timing": 0.20, + "interconnect": 0.25, + "area": 0.35, + "power": 0.10, + "robustness": 0.10, + }, +} + +_PHYSICAL_FAIL = "PHYSICAL_FAIL" + + +def evaluate_scalar_summary(feasibility, dimensions, profile: str) -> ScalarSummary: + weights = PROFILES.get(profile, PROFILES[_BALANCED]) + + if feasibility.status == _PHYSICAL_FAIL: + return ScalarSummary(score=0.0, status="FAIL", profile=profile, weights=weights) + if feasibility.status in ("NOT_VERIFIED", "UNKNOWN"): + return ScalarSummary(score=None, status="NOT_RATED", profile=profile, weights=weights) + + evaluated = { + key: dimension.value for key, dimension in dimensions.items() if dimension.value is not None + } + if not evaluated: + return ScalarSummary(score=None, status="NOT_RATED", profile=profile, weights=weights) + + # Weights re-normalize over evaluated dimensions so that a missing + # optional coordinate (e.g. power without a declared budget) cannot + # silently compress the score scale. + used_weight = sum(weights[key] for key in evaluated) + if used_weight <= 0: + return ScalarSummary(score=None, status="NOT_RATED", profile=profile, weights=weights) + score = sum(weights[key] * value for key, value in evaluated.items()) / used_weight + return ScalarSummary(score=score, status=_status(score), profile=profile, weights=weights) + + +def _status(score: float) -> str: + if score >= 90.0: + return "GREEN" + if score >= 75.0: + return "YELLOW" + if score >= 60.0: + return "ORANGE" + return "RED" diff --git a/chipcompiler/cli/command_handlers/report.py b/chipcompiler/cli/command_handlers/report.py index 2bf2d531f..44ce5f55a 100644 --- a/chipcompiler/cli/command_handlers/report.py +++ b/chipcompiler/cli/command_handlers/report.py @@ -86,16 +86,16 @@ def qor(command_input, ctx: CommandContext) -> CommandResult: extra={ "design": report.design, "overall_score": report.overall_score, - "qor_status": report.status, - "gate_status": report.gate_status, + "qor_status": report.scalar_summary.status, + "gate_status": report.feasibility.status, "dimensions": [ { - "dimension": d.label, - "score": d.score, - "weight": d.weight, - "metrics": d.metric_count, + "dimension": dimension.key, + "score": dimension.value, + "state": dimension.state, + "features": len(dimension.features), } - for d in report.dimension_scores + for dimension in report.dimension_scores ], "inspect": disclosure_cmd("ecc signoff inspect", ctx.project, ctx.run_id), }, diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index dd31da520..65d8ac83a 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -735,6 +735,19 @@ def run_step( "[QOR] %s failed to save run facts after the step succeeded", step_tag, ) + + # The workspace QoR report renders the per-step analysis + # artifacts refreshed above, so it runs after they exist; a + # failure degrades to a warning like the facts refresh. + if state == StateEnum.Success: + try: + from chipcompiler.analysis.qor import refresh_workspace_qor_report + + refresh_workspace_qor_report(self.workspace) + except Exception: + self.workspace.logger.exception( + "[QOR] %s failed to refresh the workspace QoR report", step_tag + ) except (Exception, SystemExit) as exc: failure_message = record_tool_failure(self.workspace.logger, step_tag, exc) step_error = step_error or failure_message diff --git a/chipcompiler/engine/qor_report.py b/chipcompiler/engine/qor_report.py index dbfac6069..82fd2c057 100644 --- a/chipcompiler/engine/qor_report.py +++ b/chipcompiler/engine/qor_report.py @@ -1,593 +1,30 @@ -"""Overall QoR score report for one workspace. +"""Workspace-level QoR report entry points. -Python port of the ECOS Studio GUI scoring pipeline -(ecos/gui/apps/renderer/src/utils/projectQorTrend.ts), restricted to the -single-workspace view the CLI needs: normalize the per-step schema-v3 -``analysis/qor_metrics.json`` records, select project-level records, score -each metric against the GUI fail thresholds, average per dimension, and -combine with the GUI dimension weights (deliberately NOT renormalized over -missing dimensions, matching GUI behavior). Trimmed relative to the GUI: -cross-workspace trend/regression analysis, summary blocking-issue gates, and -signoff-readiness score eligibility are project-dashboard concerns. - -Report source of truth: metrics files already carry dimension (category), -polarity (direction), and the rating gate, written by -tools/ecc/metrics.py::build_qor_metrics_payload. +The scoring implementation lives in ``chipcompiler.analysis.qor`` +(ECC-QoR draft 3); this module is the engine-side facade the CLI uses. +``home/qor_report.json`` is written by the flow engine after each +successful step via ``analysis.qor.refresh_workspace_qor_report``. """ -import dataclasses -import math -from pathlib import Path - -from chipcompiler.data import StateEnum, StepEnum -from chipcompiler.data.step_dirs import STEP_DIRECTORIES -from chipcompiler.utility.json import json_read - -# GUI FlowStep label for each canonical step that owns a scored directory. -_STEP_ENUM_TO_LABEL = { - StepEnum.SYNTHESIS.value: "Synth", - StepEnum.POST_FLOORPLAN.value: "PostFloorplan", - StepEnum.PLACEMENT.value: "Place", - StepEnum.CTS.value: "CTS", - StepEnum.LEGALIZATION.value: "Legal", - StepEnum.ROUTING.value: "Route", - StepEnum.DRC.value: "DRC", - StepEnum.LVS.value: "LVS", - StepEnum.FILLER.value: "Filler", - StepEnum.RCX.value: "RCX", - StepEnum.STA.value: "STA", - StepEnum.HARDEN.value: "Harden", -} - -# GUI FlowStep labels in flow order, derived from the canonical -# step->directory mapping (lec/postRouteLec and the label-less TimingOpt -# step carry no scored directory, so they drop out). -FLOW_STEP_DIRS = { - _STEP_ENUM_TO_LABEL[step]: directory - for step, directory in STEP_DIRECTORIES.items() - if step in _STEP_ENUM_TO_LABEL -} - -FLOW_STEPS = tuple(FLOW_STEP_DIRS) - -DIMENSION_WEIGHTS = { - "timing": 0.35, - "power_integrity": 0.25, - "routability_physical": 0.2, - "area_cost": 0.1, - "clock_robustness_dfm": 0.1, - "runtime": 0.0, -} - -DIMENSION_LABELS = { - "timing": "Timing", - "power_integrity": "Power / IR / EM", - "routability_physical": "Routability / Physical", - "area_cost": "Area", - "clock_robustness_dfm": "Clock / DFM", - "runtime": "Runtime", -} - -METRIC_FAIL_VALUES = { - "drc_count": 10, - "lvs_count": 10, - "route_wirelength": 6000, - "route_via_count": 2000, - "cts_buffer_count": 20, - "cts_buffer_area": 40, - "clock_wirelength": 400000, - "cts_clock_wirelength_max": 100000, - "cts_clock_tree_max_level": 20, - "die_area": 3000, - "core_area": 2500, - "core_utilization": 0.85, - "synthesis_cell_area": 3000, - "fanout_max": 100, - "place_hpwl": 10000, - "place_grwl": 12000, - "place_flute_wirelength": 10000, - "place_congestion_egr_overflow_total": 100, - "place_congestion_egr_overflow_max": 20, - "place_rudy_utilization_max": 1, - "place_lutrudy_utilization_max": 1, - "route_dr_total_violation_count": 50, - "route_dr_total_patch_count": 100, - "route_dr_total_wirelength": 6000, - "route_dr_total_via_count": 2000, - "route_la_total_overflow": 100, - "rcx_missing_corner_count": 9, - "sta_setup_wns": -0.2, - "sta_setup_tns": -1, - "sta_hold_wns": -0.2, - "sta_hold_tns": -1, - "sta_frequency_mhz": 100, - "sta_setup_violation_count": 1, - "sta_hold_violation_count": 1, - "sta_missing_corner_count": 1, - "harden_artifact_missing_count": 6, -} - -SLACK_METRICS = {"sta_setup_wns", "sta_setup_tns", "sta_hold_wns", "sta_hold_tns"} -CORE_UTILIZATION_TARGET = (0.45, 0.70) - -#: The 0-100 line separating the GUI pass/fail presentation. -QOR_SCORE_THRESHOLD = 60 - -GATE_STEPS = ("DRC", "LVS", "RCX", "STA") - -_ROLE_PRIORITY = {"final": 0, "gate": 1, "trend": 2, "none": 3} - - -@dataclasses.dataclass(frozen=True) -class QorMetricRecord: - step: str - metric_name: str - display_name: str - value: float - unit: str = "" - dimension: str = "" - polarity: str = "" - scope: str = "" - corner: str | None = None - project_role: str = "none" - step_role: str = "detail" - rating_score: bool = False - rating_gate: bool = False - score: float | None = None - - -@dataclasses.dataclass(frozen=True) -class QorDimensionScore: - dimension: str - label: str - weight: float - score: float - metric_count: int - - -@dataclasses.dataclass -class QorScoreReport: - workspace: str = "" - design: str = "" - overall_score: float | None = None - status: str = "Blocked" - gate_status: str = "unavailable" - area_scoring_step: str | None = None - dimension_scores: list = dataclasses.field(default_factory=list) - metrics: list = dataclasses.field(default_factory=list) - absent_dimensions: list = dataclasses.field(default_factory=list) - analyzed_steps: list = dataclasses.field(default_factory=list) - - -# --------------------------------------------------------------------------- -# Normalization (port of normalizeQorMetrics) -# --------------------------------------------------------------------------- - - -def _flexible_number(value): - """Parse a finite metric number; NaN/Infinity are invalid, not extreme.""" - if isinstance(value, bool): - return None - if isinstance(value, (int, float)): - number = float(value) - return number if math.isfinite(number) else None - if isinstance(value, str) and value.strip(): - try: - number = float(value.replace(",", "").strip()) - except ValueError: - return None - return number if math.isfinite(number) else None - return None - - -def _string_value(value): - return value if isinstance(value, str) and value else None - - -def _valid_rating(value) -> bool: - return ( - isinstance(value, dict) - and isinstance(value.get("gate"), bool) - and isinstance(value.get("score"), bool) - and isinstance(value.get("trend"), bool) - ) - - -def _normalize_metrics(step: str, payload: dict) -> list[QorMetricRecord]: - if not isinstance(payload, dict): - return [] - if payload.get("schema_version") != 3 or not isinstance(payload.get("metrics"), list): - return [] - records = [] - for raw in payload["metrics"]: - if not isinstance(raw, dict): - continue - value = _flexible_number(raw.get("value")) - dimension = _string_value(raw.get("category")) - polarity = _string_value(raw.get("direction")) - scope = _string_value(raw.get("scope")) - project_role = _string_value(raw.get("project_role")) - step_role = _string_value(raw.get("step_role")) - metric_name = _string_value(raw.get("id")) - if ( - metric_name is None - or value is None - or dimension not in DIMENSION_WEIGHTS - or polarity not in ("higher_is_better", "lower_is_better", "target_range", "trend_only") - or scope is None - or project_role not in _ROLE_PRIORITY - or step_role not in ("primary", "secondary", "detail", "hidden") - or not _valid_rating(raw.get("rating")) - ): - continue - corner = raw.get("corner") - corner = corner if isinstance(corner, str) else None - records.append( - QorMetricRecord( - step=step, - metric_name=metric_name, - display_name=_string_value(raw.get("display_name")) or metric_name, - value=value, - unit=_string_value(raw.get("unit")) or "", - dimension=dimension, - polarity=polarity, - scope=scope, - corner=corner, - project_role=project_role, - step_role=step_role, - rating_score=raw["rating"]["score"], - rating_gate=raw["rating"]["gate"], - ) - ) - return records - - -# --------------------------------------------------------------------------- -# Scoring (port of scoreRecord / buildDimensionScores / weightedOverallScore) -# --------------------------------------------------------------------------- - - -def _clamp_score(score: float) -> float: - return max(0.0, min(100.0, score)) - - -def _round_score(score: float) -> float: - return round(score, 1) - - -def _score_target_range(value: float, min_target: float, max_target: float, fail: float) -> float: - if min_target <= value <= max_target: - return 100.0 - if value < min_target: - return _clamp_score(100 * value / min_target) - return _clamp_score(100 * (fail - value) / (fail - max_target)) - - -def score_record(record: QorMetricRecord) -> float | None: - if record.polarity == "trend_only": - return None - if record.metric_name not in METRIC_FAIL_VALUES: - return None - - if record.metric_name in SLACK_METRICS: - fail = METRIC_FAIL_VALUES[record.metric_name] - if fail >= 0: - return None - if record.value >= 0: - return 100.0 - return _clamp_score(100 * (record.value - fail) / -fail) - - if record.polarity == "target_range": - if record.metric_name == "core_utilization": - return _score_target_range( - record.value, *CORE_UTILIZATION_TARGET, METRIC_FAIL_VALUES["core_utilization"] - ) - return None - - fail = METRIC_FAIL_VALUES[record.metric_name] - if fail <= 0: - return None - if record.polarity == "lower_is_better": - return _clamp_score(100 * (fail - record.value) / fail) - return _clamp_score(100 * record.value / fail) +from chipcompiler.analysis.qor import ( + build_qor_analysis, + render_qor_analysis, +) +from chipcompiler.analysis.qor.loader import load_workspace_qor_inputs +__all__ = ["build_qor_report", "generate_qor_report"] -def _record_key(record: QorMetricRecord) -> tuple: - return (record.metric_name, record.scope, record.corner or "") - -def _select_project_records(records, area_scoring_step) -> list[QorMetricRecord]: - selected: dict[tuple, QorMetricRecord] = {} - for record in records: - if record.project_role == "none": - continue - if record.dimension == "area_cost" and record.step != area_scoring_step: - continue - current = selected.get(_record_key(record)) - if current is None or _selection_rank(record) < _selection_rank(current): - selected[_record_key(record)] = record - return sorted(selected.values(), key=lambda r: r.metric_name) - - -def _selection_rank(record: QorMetricRecord) -> tuple: - return (_ROLE_PRIORITY[record.project_role], -FLOW_STEPS.index(record.step)) - - -def _resolve_area_scoring_step(records, flow_steps_by_label) -> str | None: - for step in reversed(FLOW_STEPS): - if flow_steps_by_label.get(step) != StateEnum.Success.value: - continue - if any(r.step == step and r.dimension == "area_cost" and r.rating_score for r in records): - return step - return None - - -def _gate_status(flow_steps_by_label) -> str: - # A pass verdict requires every gate step to be present and successful: - # a successful DRC with LVS/RCX/STA absent is partial evidence, not a - # pass. - known = [step for step in GATE_STEPS if step in flow_steps_by_label] - if not known: - return "unavailable" - states = {flow_steps_by_label[step] for step in known} - if states & {StateEnum.Imcomplete.value, StateEnum.Invalid.value}: - return "blocked" - if states - {StateEnum.Success.value}: - return "incomplete" - if len(known) < len(GATE_STEPS): - return "incomplete" - return "pass" - - -def _flow_completion_state(states) -> str: - """Classify a workspace's step-state set explicitly. - - Only an all-Success ledger completes a flow. - """ - from chipcompiler.data.step import FINISHED_STEP_STATES - - values = list(states) - if any(state in (StateEnum.Imcomplete.value, StateEnum.Invalid.value) for state in values): - return "failed" - if not values: - return "not_started" - if all(state in FINISHED_STEP_STATES for state in values): - return "complete" - if any(state == StateEnum.Ongoing.value for state in values): - return "running" - if all(state == StateEnum.Unstart.value for state in values): - return "not_started" - return "in_progress" - - -def _workspace_status(flow_state: str, score: float | None, gate: str) -> str: - if flow_state == "failed": - return "Red" - if flow_state in ("running", "in_progress", "not_started"): - return "Blocked" - if gate == "blocked": - return "Orange" - if gate == "incomplete": - return "Yellow" - if score is None: - return "Blocked" - if score >= 40: - return "Green" - if score >= 25: - return "Yellow" - if score >= 10: - return "Orange" - return "Red" - - -def _weighted_overall(dimension_scores: dict) -> float | None: - weighted_total = 0.0 - used_weight = 0.0 - for dimension, score in dimension_scores.items(): - weight = DIMENSION_WEIGHTS[dimension] - if weight <= 0: - continue - weighted_total += score * weight - used_weight += weight - if used_weight == 0: - return None - # GUI behavior: no renormalization over missing dimensions. - return weighted_total - - -# --------------------------------------------------------------------------- -# Workspace collection and rendering -# --------------------------------------------------------------------------- - - -def _flow_states(workspace) -> dict[str, str]: - flow = getattr(workspace, "flow", None) - data = getattr(flow, "data", None) - if not isinstance(data, dict) or not data: - # load_workspace leaves flow.data empty; the persisted file is the - # source of truth (same fallback the signoff collector uses). - data = json_read(Path(workspace.directory or "") / "home" / "flow.json") - steps = data.get("steps") if isinstance(data, dict) else None - states = {} - if isinstance(steps, list): - for raw in steps: - if isinstance(raw, dict) and isinstance(raw.get("name"), str): - states[raw["name"]] = raw.get("state") if isinstance(raw.get("state"), str) else "" - return states - - -def _workspace_parameters(workspace, workspace_root: Path) -> dict: - parameters = getattr(getattr(workspace, "parameters", None), "data", None) - if isinstance(parameters, dict): - return parameters - legacy = json_read(workspace_root / "home" / "parameters.json") - return legacy if isinstance(legacy, dict) else {} - - -def build_qor_report(workspace) -> QorScoreReport: - """Score one workspace's current analysis outputs the way the GUI does.""" - workspace_root = Path(workspace.directory or "") - raw_states = _flow_states(workspace) - flow_steps_by_label = { - _STEP_ENUM_TO_LABEL.get(name, name): state for name, state in raw_states.items() - } - - records: list[QorMetricRecord] = [] - analyzed_steps = [] - for step, dir_name in FLOW_STEP_DIRS.items(): - # Only currently successful steps score: invalidation keeps a step's - # analysis outputs on disk, so without this gate a stale suffix would - # report its obsolete metrics as current. - if flow_steps_by_label.get(step) != StateEnum.Success.value: - continue - payload = json_read(workspace_root / dir_name / "analysis" / "qor_metrics.json") - if not payload: - continue - analyzed_steps.append(step) - records.extend(_normalize_metrics(step, payload)) - - area_scoring_step = _resolve_area_scoring_step(records, flow_steps_by_label) - project_records = _select_project_records(records, area_scoring_step) - - scored: list[QorMetricRecord] = [] - by_dimension: dict[str, list[float]] = {} - for record in project_records: - # GUI gate: only rating.score records feed dimension averages; the - # rest stay in the table marked as trend-only. - score = score_record(record) if record.rating_score else None - scored.append(dataclasses.replace(record, score=score)) - if score is not None: - by_dimension.setdefault(record.dimension, []).append(score) - - dimension_averages = { - dimension: _round_score(sum(scores) / len(scores)) - for dimension, scores in by_dimension.items() - } - overall = _weighted_overall(dimension_averages) - overall_score = _round_score(overall) if overall is not None else None - - gate = _gate_status(flow_steps_by_label) - flow_state = _flow_completion_state(flow_steps_by_label.values()) - - parameters = _workspace_parameters(workspace, workspace_root) - workspace_design = getattr(workspace, "design", None) - design = ( - getattr(workspace_design, "name", "") - or getattr(workspace, "name", "") - or parameters.get("design") - or parameters.get("Design") - or "" - ) - - dimension_scores = [ - QorDimensionScore( - dimension=dimension, - label=DIMENSION_LABELS[dimension], - weight=DIMENSION_WEIGHTS[dimension], - score=dimension_averages[dimension], - metric_count=len(by_dimension[dimension]), - ) - for dimension in DIMENSION_WEIGHTS - if dimension in dimension_averages - ] - absent = [ - DIMENSION_LABELS[dimension] - for dimension in DIMENSION_WEIGHTS - if dimension not in dimension_averages and DIMENSION_WEIGHTS[dimension] > 0 - ] - - return QorScoreReport( - workspace=str(workspace_root), - design=design, - overall_score=overall_score, - status=_workspace_status(flow_state, overall_score, gate), - gate_status=gate, - area_scoring_step=area_scoring_step, - dimension_scores=dimension_scores, - metrics=scored, - absent_dimensions=absent, - analyzed_steps=analyzed_steps, - ) - - -WIDTH = 78 - - -def _pad(text: str, width: int) -> str: - if len(text) >= width: - return text - return text + " " * (width - len(text)) - - -def _fmt(value, unit: str = "") -> str: - if value is None: - return "—" - text = f"{value:g}" if isinstance(value, float) else str(value) - return f"{text} {unit}".rstrip() if unit else text +def build_qor_report(workspace): + """Score one workspace's current analysis outputs (QorAnalysis).""" + return build_qor_analysis(workspace) def generate_qor_report(workspace, report=None) -> str: - """Render the overall QoR score report as GUI-parity text. + """Render the workspace QoR text report. Pass a prebuilt *report* to render the exact snapshot the caller already collected instead of re-traversing the workspace. """ report = report if report is not None else build_qor_report(workspace) - lines: list[str] = [] - score_text = f"{report.overall_score:g}" if report.overall_score is not None else "—" - verdict = ( - "PASS" - if report.overall_score is not None and report.overall_score >= QOR_SCORE_THRESHOLD - else "BELOW THRESHOLD" - if report.overall_score is not None - else "NOT RATED" - ) - title = f" ECC QOR OVERALL SCORE — {score_text}/100 ({verdict}) " - side = max(0, (WIDTH - len(title)) // 2) - lines.append("=" * side + title + "=" * (WIDTH - side - len(title))) - lines.append(f"Design : {report.design or '—'}") - lines.append(f"Workspace : {report.workspace}") - lines.append(f"Flow status : {report.status} gate: {report.gate_status}") - if report.area_scoring_step: - lines.append(f"Area scoring step : {report.area_scoring_step}") - lines.append(f"Analyzed steps : {', '.join(report.analyzed_steps) or '—'}") - lines.append(f"Pass threshold : {QOR_SCORE_THRESHOLD} (weights not renormalized)") - lines.append("=" * WIDTH) - lines.append("") - - lines.append("[ DIMENSION SCORES ]") - lines.append("-" * WIDTH) - lines.append(f" {_pad('Dimension', 24)} {_pad('Score', 9)} {_pad('Weight', 8)} Metrics") - for dimension in report.dimension_scores: - lines.append( - f" {_pad(dimension.label, 24)} {_pad(f'{dimension.score:g}', 9)}" - f" {_pad(f'{dimension.weight:g}', 8)} {dimension.metric_count}" - ) - if report.absent_dimensions: - lines.append("") - lines.append(" Absent dimensions (no scoreable metrics):") - for label in report.absent_dimensions: - lines.append(f" - {label}") - lines.append("") - - lines.append("[ METRIC SCORES ]") - lines.append("-" * WIDTH) - lines.append( - f" {_pad('Metric', 34)} {_pad('Step', 8)} {_pad('Corner', 12)} {_pad('Value', 14)} Score" - ) - lines.append(" " + "-" * (WIDTH - 4)) - for record in report.metrics: - corner = record.corner or "" - value = _fmt(record.value, record.unit) - score = f"{record.score:g}" if record.score is not None else "trend" - lines.append( - f" {_pad(record.display_name, 34)} {_pad(record.step, 8)} {_pad(corner, 12)}" - f" {_pad(value, 14)} {score}" - ) - if not report.metrics: - lines.append(" (no project-level QoR metrics available)") - - lines.append("") - lines.append("=" * WIDTH) - lines.append("END OF QOR REPORT") - return "\n".join(lines) + return render_qor_analysis(report, load_workspace_qor_inputs(workspace)) diff --git a/test/analysis/__init__.py b/test/analysis/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/analysis/qor/__init__.py b/test/analysis/qor/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/test/analysis/qor/helpers.py b/test/analysis/qor/helpers.py new file mode 100644 index 000000000..17a36592a --- /dev/null +++ b/test/analysis/qor/helpers.py @@ -0,0 +1,107 @@ +"""Shared fixtures for the QoR engine tests.""" + +from chipcompiler.analysis.qor.loader import CornerSlack, MetricRecord, QorInputs + +SUCCESS = "Success" + + +def make_metric(metric_id, value, step="sta", role="final", unit="", corner=None): + return MetricRecord( + metric_id=metric_id, + value=value, + unit=unit, + step=step, + project_role=role, + corner=corner, + source={"kind": "analysis", "path": f"{step}_ecc/analysis/qor_metrics.json"}, + ) + + +def make_inputs(metrics=None, flow_states=None, **overrides) -> QorInputs: + metrics = metrics or {} + + def _value(metric_id): + return metrics[metric_id].value if metric_id in metrics else None + + derived = { + # Mirror the loader: corner/SPEF counts come from payload metrics. + "sta_expected_corners": _value("sta_expected_corner_count"), + "rcx_spef_count": _value("rcx_spef_file_count"), + "rcx_expected_spef": _value("rcx_expected_corner_count"), + } + derived.update(overrides) + inputs = QorInputs( + design="gcd", + workspace_path="/tmp/ws", + flow_states=flow_states if flow_states is not None else _all_success(), + metrics=metrics, + ) + for key, value in derived.items(): + setattr(inputs, key, value) + return inputs + + +def _all_success(): + return { + step: SUCCESS + for step in ( + "Synthesis", + "Floorplan", + "place", + "CTS", + "route", + "drc", + "lvs", + "RCX", + "sta", + "Harden", + ) + } + + +def gcd_metrics() -> dict: + """Reference GCD fixture inputs (spec §12.1).""" + records = [ + make_metric("synthesis_cell_area", 800.0, step="Synthesis"), + make_metric("core_area", 1538.46, step="Floorplan"), + make_metric("core_utilization", 0.52, step="Floorplan"), + make_metric("place_hpwl", 3143.52, step="place"), + make_metric("place_grwl", 3812.00, step="place"), + make_metric("place_rudy_utilization_max", 0.0, step="place"), + make_metric("cts_buffer_count", 4, step="CTS"), + make_metric("cts_inverter_count", 0, step="CTS"), + make_metric("clock_path_max_buffer", 4, step="CTS"), + make_metric("clock_path_min_buffer", 4, step="CTS"), + make_metric("clock_wirelength", 610.0, step="CTS"), + make_metric("route_wirelength", 4315.53, step="route"), + make_metric("route_via_count", 608, step="route"), + make_metric("drc_count", 0, step="drc"), + make_metric("lvs_count", 0, step="lvs"), + make_metric("rcx_spef_file_count", 2, step="RCX"), + make_metric("rcx_expected_corner_count", 2, step="RCX"), + make_metric("rcx_missing_corner_count", 0, step="RCX"), + make_metric("rcx_worst_total_capacitance_ff", 120.0, step="RCX"), + make_metric("rcx_worst_coupling_capacitance_ff", 30.0, step="RCX"), + make_metric("sta_setup_wns", 16.622, step="sta"), + make_metric("sta_setup_tns", 0.0, step="sta"), + make_metric("sta_hold_wns", 0.1, step="sta"), + make_metric("sta_hold_tns", 0.0, step="sta"), + make_metric("sta_setup_violation_count", 0, step="sta"), + make_metric("sta_hold_violation_count", 0, step="sta"), + make_metric("sta_expected_corner_count", 2, step="sta"), + make_metric("sta_missing_corner_count", 0, step="sta"), + make_metric("sta_worst_setup_corner", "MAX_125", step="sta"), + make_metric("harden_artifact_missing_count", 0, step="Harden"), + ] + return {record.metric_id: record for record in records} + + +def gcd_corners(): + """Two corners reproducing the spec §12.1 dispersions. + + WS spread: 18.980 - 16.622 = 2.358 ns; hold spread: 0.274 - 0.100 = 0.174 ns. + """ + return [ + CornerSlack(corner="MAX_125", setup_ws=16.622, hold_ws=0.100, setup_nvp=0, hold_nvp=0), + CornerSlack(corner="MAX_M40", setup_ws=18.980, hold_ws=0.274, setup_nvp=0, hold_nvp=0), + ] diff --git a/test/analysis/qor/test_calibration.py b/test/analysis/qor/test_calibration.py new file mode 100644 index 000000000..896e3534d --- /dev/null +++ b/test/analysis/qor/test_calibration.py @@ -0,0 +1,56 @@ +import pytest + +from chipcompiler.analysis.qor.calibration import ( + CalibrationError, + clamp01, + psi_cost, + psi_target, +) + + +class TestPsiCost: + def test_preferred_plateau_gives_full_credit(self): + # Approaching the geometric lower bound is never penalized. + assert psi_cost(1.05, 1.25, 1.75) == 1.0 + assert psi_cost(1.20, 1.25, 1.75) == 1.0 + assert psi_cost(1.25, 1.25, 1.75) == 1.0 + + def test_linear_penalty_slope(self): + assert psi_cost(1.50, 1.25, 1.75) == pytest.approx(0.5) + assert psi_cost(1.75, 1.25, 1.75) == 0.0 + + def test_beyond_failure_is_clamped_to_zero(self): + assert psi_cost(2.5, 1.25, 1.75) == 0.0 + + def test_monotone_non_increasing(self): + scores = [psi_cost(x / 100.0, 1.25, 1.75) for x in range(100, 200, 5)] + assert scores == sorted(scores, reverse=True) + + def test_invalid_thresholds_rejected(self): + with pytest.raises(CalibrationError): + psi_cost(1.0, 1.75, 1.25) + + +class TestPsiTarget: + def test_target_interval_full_credit(self): + assert psi_target(0.52, 0.45, 0.70, 0.85) == 1.0 + assert psi_target(0.45, 0.45, 0.70, 0.85) == 1.0 + assert psi_target(0.70, 0.45, 0.70, 0.85) == 1.0 + + def test_under_allocation_penalized(self): + assert psi_target(0.225, 0.45, 0.70, 0.85) == pytest.approx(0.5) + + def test_over_allocation_penalized(self): + assert psi_target(0.85, 0.45, 0.70, 0.85) == 0.0 + assert psi_target(0.775, 0.45, 0.70, 0.85) == pytest.approx(0.5) + + def test_strict_positivity_required(self): + with pytest.raises(CalibrationError): + psi_target(0.1, 0.0, 0.7, 0.85) + + +class TestClamp01: + def test_clamps(self): + assert clamp01(-0.5) == 0.0 + assert clamp01(0.25) == 0.25 + assert clamp01(1.5) == 1.0 diff --git a/test/analysis/qor/test_diagnosis.py b/test/analysis/qor/test_diagnosis.py new file mode 100644 index 000000000..1c61eccc2 --- /dev/null +++ b/test/analysis/qor/test_diagnosis.py @@ -0,0 +1,86 @@ +import pytest + +from chipcompiler.analysis.qor.diagnosis import build_diagnoses +from chipcompiler.analysis.qor.dimensions import evaluate_dimensions +from chipcompiler.analysis.qor.feasibility import evaluate_feasibility +from chipcompiler.analysis.qor.features import compute_features +from chipcompiler.analysis.qor.interventions import prioritize +from test.analysis.qor.helpers import gcd_corners, gcd_metrics, make_inputs + + +def _diagnoses(inputs): + bundle = compute_features(inputs) + dimensions = evaluate_dimensions(bundle, inputs) + feasibility = evaluate_feasibility(inputs) + return build_diagnoses(feasibility, dimensions, bundle, inputs) + + +class TestGateSeverity: + def test_signoff_failures_are_tier_one_and_above_bottlenecks(self): + metrics = gcd_metrics() + metrics["drc_count"].value = 1 + diagnoses = _diagnoses(make_inputs(metrics, tclk_ns=20.0)) + signoff = next(d for d in diagnoses if d.diagnosis_id.startswith("diag.signoff")) + assert signoff.severity >= 0.80 + non_signoff = [d for d in diagnoses if not d.diagnosis_id.startswith("diag.signoff")] + assert all(d.severity < 0.80 for d in non_signoff) + + def test_violation_magnitude_preserves_ordering(self): + mild = gcd_metrics() + mild["sta_setup_wns"].value = -0.001 + severe = gcd_metrics() + severe["sta_setup_wns"].value = -2.5 + mild_severity = next( + d + for d in _diagnoses(make_inputs(mild, tclk_ns=20.0)) + if d.diagnosis_id == "diag.signoff.gate_setup_slack" + ).severity + severe_severity = next( + d + for d in _diagnoses(make_inputs(severe, tclk_ns=20.0)) + if d.diagnosis_id == "diag.signoff.gate_setup_slack" + ).severity + assert severe_severity > mild_severity + # tau_timing_fail = 0.20 * 20ns = 4ns: (-2.5)/4 = 0.625 magnitude. + assert severe_severity == pytest.approx(0.80 + 0.20 * 0.625) + assert mild_severity == pytest.approx(0.80, abs=0.01) + + def test_clean_design_has_no_fail_diagnoses(self): + diagnoses = _diagnoses(make_inputs(gcd_metrics(), corners=gcd_corners(), tclk_ns=20.0)) + assert all(d.state != "FAIL" for d in diagnoses) + + +class TestQualityAndOpportunityDiagnoses: + def test_over_provisioned_timing_is_opportunity(self): + diagnoses = _diagnoses(make_inputs(gcd_metrics(), corners=gcd_corners(), tclk_ns=20.0)) + over = next(d for d in diagnoses if d.diagnosis_id == "diag.timing.over_provisioned") + assert over.state == "OPPORTUNITY" + # (16.622 - 4.0) / (20.0 - 4.0) = 0.789 (spec eq. 57). + assert over.severity == pytest.approx(0.789, abs=0.001) + assert over.interventions[0].tier == "TIER_3_OPPORTUNITY" + + def test_bottleneck_below_threshold_reports_watch(self): + metrics = gcd_metrics() + metrics["core_utilization"].value = 0.40 # area quality 88.9 -> no + metrics["place_rudy_utilization_max"].value = 1.5 # S_cong = 1.5 + diagnoses = _diagnoses(make_inputs(metrics, tclk_ns=20.0)) + congestion = next(d for d in diagnoses if d.diagnosis_id == "diag.place.congestion") + assert congestion.severity == pytest.approx(1.0) + assert congestion.state == "WATCH" + + def test_non_causal_language(self): + metrics = gcd_metrics() + metrics["drc_count"].value = 2 + for diagnosis in _diagnoses(make_inputs(metrics, tclk_ns=20.0)): + text = diagnosis.interpretation.lower() + assert "caused" not in text + assert "proves" not in text + + def test_prioritization_is_tier_lexicographic(self): + metrics = gcd_metrics() + metrics["drc_count"].value = 5 + diagnoses = _diagnoses(make_inputs(metrics, corners=gcd_corners(), tclk_ns=20.0)) + ranked = prioritize(diagnoses) + tier_rank = {"TIER_1_FEASIBILITY": 0, "TIER_2_BOTTLENECK": 1, "TIER_3_OPPORTUNITY": 2} + tiers = [intervention.tier for intervention in ranked] + assert tiers == sorted(tiers, key=lambda tier: tier_rank[tier]) diff --git a/test/analysis/qor/test_dimensions.py b/test/analysis/qor/test_dimensions.py new file mode 100644 index 000000000..a99aa105c --- /dev/null +++ b/test/analysis/qor/test_dimensions.py @@ -0,0 +1,135 @@ +import pytest + +from chipcompiler.analysis.qor.calibration import TAU_AREA_FAIL, psi_cost +from chipcompiler.analysis.qor.dimensions import evaluate_dimensions +from chipcompiler.analysis.qor.features import compute_features +from test.analysis.qor.helpers import gcd_corners, gcd_metrics, make_inputs, make_metric + + +def _dimensions(inputs): + bundle = compute_features(inputs) + return bundle, evaluate_dimensions(bundle, inputs) + + +class TestTimingQuality: + def test_positive_margins_differentiate(self): + # +50ps of a 1ns clock (5% guardband) reaches full credit. + _, dims = _dimensions( + make_inputs({"sta_setup_wns": make_metric("sta_setup_wns", 0.05)}, tclk_ns=1.0) + ) + assert dims["timing"].value == pytest.approx(100.0) + + def test_small_positive_margin_is_partial(self): + _, dims = _dimensions( + make_inputs({"sta_setup_wns": make_metric("sta_setup_wns", 0.025)}, tclk_ns=1.0) + ) + assert dims["timing"].value == pytest.approx(75.0) + + def test_zero_slack_is_fifty(self): + _, dims = _dimensions( + make_inputs({"sta_setup_wns": make_metric("sta_setup_wns", 0.0)}, tclk_ns=1.0) + ) + assert dims["timing"].value == pytest.approx(50.0) + assert dims["timing"].state == "WATCH" + + def test_negative_slack_scores_below_fifty_and_fails(self): + _, dims = _dimensions( + make_inputs({"sta_setup_wns": make_metric("sta_setup_wns", -0.1)}, tclk_ns=1.0) + ) + assert 0.0 <= dims["timing"].value < 50.0 + assert dims["timing"].state == "FAIL" + + def test_monotone_in_signed_slack(self): + values = [] + for ws in (-0.2, -0.1, 0.0, 0.01, 0.05, 0.2): + _, dims = _dimensions( + make_inputs({"sta_setup_wns": make_metric("sta_setup_wns", ws)}, tclk_ns=1.0) + ) + values.append(dims["timing"].value) + assert values == sorted(values) + + def test_missing_tclk_is_unknown_not_zero(self): + _, dims = _dimensions(make_inputs({"sta_setup_wns": make_metric("sta_setup_wns", 0.5)})) + assert dims["timing"].value is None + assert dims["timing"].state == "UNKNOWN" + + +class TestInterconnectQuality: + def test_place_ladder_under_preferred_threshold_is_full_credit(self): + # GCD ladder: I_place = 3812/3143.52 = 1.213 <= tau_pref, S_cong small. + _, dims = _dimensions(make_inputs(gcd_metrics(), tclk_ns=20.0)) + assert dims["interconnect"].value == pytest.approx(100.0) + + def test_qi_matches_spec_calibration_for_itotal(self): + # Pin the spec §12.1 calibration point: I_total=1.373, S_cong=0. + assert 100.0 * psi_cost(1.373, 1.25, 1.75) == pytest.approx(75.4, abs=0.1) + + def test_congestion_multiplier_applies(self): + metrics = gcd_metrics() + metrics["place_rudy_utilization_max"].value = 2.0 # S_cong >= 1 -> zero credit + _, dims = _dimensions(make_inputs(metrics, tclk_ns=20.0)) + assert dims["interconnect"].value == pytest.approx(0.0) + + +class TestAreaQuality: + def test_target_interval_full_credit(self): + _, dims = _dimensions(make_inputs(gcd_metrics(), tclk_ns=20.0)) + assert dims["area"].value == pytest.approx(100.0) + + def test_over_utilization_fails_at_fail_bound(self): + metrics = gcd_metrics() + metrics["core_utilization"].value = TAU_AREA_FAIL + _, dims = _dimensions(make_inputs(metrics)) + assert dims["area"].value == pytest.approx(0.0) + assert dims["area"].state == "FAIL" + + def test_under_utilization_penalized(self): + metrics = gcd_metrics() + metrics["core_utilization"].value = 0.225 + _, dims = _dimensions(make_inputs(metrics)) + assert dims["area"].value == pytest.approx(50.0) + + def test_missing_utilization_is_unknown(self): + metrics = gcd_metrics() + del metrics["core_utilization"] + _, dims = _dimensions(make_inputs(metrics)) + assert dims["area"].value is None + assert dims["area"].state == "UNKNOWN" + + +class TestPowerQuality: + def test_no_budget_is_unknown_not_zero(self): + _, dims = _dimensions(make_inputs(gcd_metrics())) + assert dims["power"].value is None + assert dims["power"].state == "UNKNOWN" + + def test_budget_operating_points(self): + metrics = gcd_metrics() + for total, expected in ((0.0, 100.0), (500.0, 50.0), (1000.0, 0.0), (1500.0, 0.0)): + inputs = make_inputs(metrics, power_budget_uw=1000.0, power_total_uw=total) + bundle = compute_features(inputs) + dims = evaluate_dimensions(bundle, inputs) + assert dims["power"].value == pytest.approx(expected), total + + +class TestRobustnessQuality: + def test_spec_reference_value(self): + # QR = 100*(1 - [0.5*0 + 0.5*(2.358/20)]) = 94.105 (spec §12.1). + _, dims = _dimensions(make_inputs(gcd_metrics(), corners=gcd_corners(), tclk_ns=20.0)) + assert dims["robustness"].value == pytest.approx(94.105, abs=0.01) + + def test_missing_pvt_renormalizes_to_cts_weight(self): + metrics = gcd_metrics() + metrics["clock_path_max_buffer"].value = 5 + metrics["clock_path_min_buffer"].value = 1 + _, dims = _dimensions(make_inputs(metrics, tclk_ns=20.0)) + # imbalance = (5-1)/5 = 0.8; the single contributor renormalizes to w=1. + assert dims["robustness"].value == pytest.approx(20.0) + + def test_missing_both_contributors_is_unknown(self): + metrics = gcd_metrics() + del metrics["clock_path_max_buffer"] + del metrics["clock_path_min_buffer"] + _, dims = _dimensions(make_inputs(metrics)) + assert dims["robustness"].value is None + assert dims["robustness"].state == "UNKNOWN" diff --git a/test/analysis/qor/test_evidence.py b/test/analysis/qor/test_evidence.py new file mode 100644 index 000000000..d7e65e148 --- /dev/null +++ b/test/analysis/qor/test_evidence.py @@ -0,0 +1,64 @@ +import pytest + +from chipcompiler.analysis.qor.evidence import evaluate_evidence +from chipcompiler.analysis.qor.features import compute_features +from test.analysis.qor.helpers import gcd_metrics, make_inputs + + +def _evidence(inputs): + bundle = compute_features(inputs) + return evaluate_evidence(inputs, bundle) + + +class TestEvidenceIndex: + def test_complete_evidence_is_high(self): + evidence = _evidence(make_inputs(gcd_metrics(), corners=[], tclk_ns=20.0)) + assert evidence.state == "HIGH" + assert evidence.index == pytest.approx(100.0) + + def test_no_analyzed_steps_is_not_verified(self): + evidence = _evidence(make_inputs()) + assert evidence.index is None + assert evidence.state == "NOT_VERIFIED" + + def test_parse_failures_degrade_integrity(self): + inputs = make_inputs(gcd_metrics()) + inputs.parse_failures = 1 + inputs.analyzed_steps = list(inputs.analyzed_steps) + evidence = _evidence(inputs) + assert evidence.integrity == pytest.approx(0.0) + assert evidence.state == "INSUFFICIENT" + + def test_invalid_selectors_reduce_integrity(self): + inputs = make_inputs(gcd_metrics()) + inputs.analyzed_steps = ["sta", "route"] + inputs.invalid_selector_count = 1 + evidence = _evidence(inputs) + assert evidence.integrity == pytest.approx(1.0 - 1 / 2) + + def test_missing_sta_corners_degrade_coverage(self): + metrics = gcd_metrics() + metrics["sta_missing_corner_count"].value = 1 + evidence = _evidence(make_inputs(metrics)) + # STA loads 1/2; RCX loads 2/2 -> coverage averages to 0.75. + assert evidence.coverage == pytest.approx(0.75) + assert evidence.state == "MODERATE" + + def test_consistency_c2_detects_ws_nvp_contradiction(self): + metrics = gcd_metrics() + metrics["sta_setup_wns"].value = -0.5 # WS < 0 but NVP == 0 + evidence = _evidence(make_inputs(metrics)) + # C1 drops out (INCOMPATIBLE), C2 fails, C3 passes. + assert evidence.consistency == pytest.approx(0.5) + + def test_consistency_c1_not_applicable_when_incompatible(self): + evidence = _evidence(make_inputs(gcd_metrics())) + # Route side is INCOMPATIBLE (CTS ran), so C1 drops out. + assert evidence.consistency == pytest.approx(1.0) + + def test_zero_expected_corners_is_not_a_division_error(self): + metrics = gcd_metrics() + for metric_id in ("sta_expected_corner_count", "sta_missing_corner_count"): + metrics[metric_id].value = 0 + evidence = _evidence(make_inputs(metrics)) + assert evidence.coverage == pytest.approx(1.0) # RCX-only coverage diff --git a/test/analysis/qor/test_feasibility.py b/test/analysis/qor/test_feasibility.py new file mode 100644 index 000000000..97a49d0ab --- /dev/null +++ b/test/analysis/qor/test_feasibility.py @@ -0,0 +1,82 @@ +from chipcompiler.analysis.qor.feasibility import evaluate_feasibility +from test.analysis.qor.helpers import gcd_metrics, make_inputs + +ALL_SUCCESS = { + step: "Success" + for step in ( + "Synthesis", + "Floorplan", + "place", + "CTS", + "route", + "drc", + "lvs", + "RCX", + "sta", + "Harden", + ) +} + + +def _status(inputs): + return evaluate_feasibility(inputs).status + + +class TestFeasibilityReduction: + def test_clean_signoff_passes(self): + assert _status(make_inputs(gcd_metrics(), ALL_SUCCESS)) == "PASS" + + def test_any_failed_gate_vetoes_everything(self): + metrics = gcd_metrics() + metrics["drc_count"].value = 3 + inputs = make_inputs(metrics, ALL_SUCCESS) + feasibility = evaluate_feasibility(inputs) + assert feasibility.status == "PHYSICAL_FAIL" + failed = [gate for gate in feasibility.gates if gate.state == "failed"] + assert [gate.id for gate in failed] == ["GATE_DRC"] + + def test_negative_setup_slack_fails(self): + metrics = gcd_metrics() + metrics["sta_setup_wns"].value = -0.25 + assert _status(make_inputs(metrics, ALL_SUCCESS)) == "PHYSICAL_FAIL" + + def test_missing_stage_is_not_verified_never_fail(self): + # An omitted verification stage must not become a physical failure. + states = dict(ALL_SUCCESS) + states["drc"] = "Unstart" + assert _status(make_inputs(gcd_metrics(), states)) == "NOT_VERIFIED" + + def test_completed_stage_with_missing_evidence_is_unknown(self): + metrics = gcd_metrics() + del metrics["drc_count"] + assert _status(make_inputs(metrics, ALL_SUCCESS)) == "UNKNOWN" + + def test_failure_outranks_missing_evidence(self): + metrics = gcd_metrics() + metrics["drc_count"].value = 1 + del metrics["lvs_count"] # completed stage, corrupt evidence + assert _status(make_inputs(metrics, ALL_SUCCESS)) == "PHYSICAL_FAIL" + + def test_hold_gate_uses_signed_worst_slack(self): + metrics = gcd_metrics() + metrics["sta_hold_wns"].value = -0.01 + inputs = make_inputs(metrics, ALL_SUCCESS) + feasibility = evaluate_feasibility(inputs) + hold = next(gate for gate in feasibility.gates if gate.id == "GATE_HOLD_SLACK") + assert hold.state == "failed" + assert hold.timing_slack.wns_ns == -0.01 + assert hold.timing_slack.ws_ns == -0.01 + + def test_setup_slack_gate_carries_signed_and_clamped_views(self): + inputs = make_inputs(gcd_metrics(), ALL_SUCCESS) + setup = next( + gate for gate in evaluate_feasibility(inputs).gates if gate.id == "GATE_SETUP_SLACK" + ) + assert setup.timing_slack.ws_ns == 16.622 + assert setup.timing_slack.wns_ns == 0.0 + assert setup.timing_slack.nvp == 0 + + def test_running_stage_is_not_verified(self): + states = dict(ALL_SUCCESS) + states["sta"] = "Ongoing" + assert _status(make_inputs(gcd_metrics(), states)) == "NOT_VERIFIED" diff --git a/test/analysis/qor/test_loader.py b/test/analysis/qor/test_loader.py new file mode 100644 index 000000000..c9db8c9f7 --- /dev/null +++ b/test/analysis/qor/test_loader.py @@ -0,0 +1,217 @@ +import json +import os +from types import SimpleNamespace + +import pytest + +from chipcompiler.analysis.qor.loader import load_workspace_qor_inputs + + +def _write(path, payload): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + json.dump(payload, f) + + +def _metric(metric_id, value, project_role="final", **overrides): + record = { + "id": metric_id, + "display_name": metric_id, + "value": value, + "unit": "", + "category": "timing", + "direction": "lower_is_better", + "scope": "project", + "corner": None, + "project_role": project_role, + "step_role": "primary", + "rating": {"gate": False, "score": True, "trend": True}, + "source": {"kind": "analysis", "path": "x"}, + } + record.update(overrides) + return record + + +def _payload(metrics): + return { + "schema_version": 3, + "kind": "qor_metrics", + "integrity": {"status": "pass", "invalid_metric_source_ids": [], "invalid_detail_ids": []}, + "metrics": metrics, + } + + +def _write_step_payload(root, directory, metrics): + _write( + os.path.join(root, directory, "analysis", "qor_metrics.json"), + _payload(metrics), + ) + + +_STEP_DIRECTORIES = { + "Synthesis": "Synthesis_yosys", + "Floorplan": "Floorplan_ecc", + "place": "place_dreamplace", + "CTS": "CTS_ecc", + "route": "route_ecc", + "drc": "drc_ecc", + "lvs": "lvs_ecc", + "RCX": "RCX_ecc", + "sta": "sta_ecc", + "Harden": "Harden_ecc", +} + + +def _make_workspace(tmp_path, steps): + root = str(tmp_path / "ws") + _write( + os.path.join(root, "home", "flow.json"), + {"steps": [{"name": name, "tool": "ecc", "state": state} for name, state in steps.items()]}, + ) + _write( + os.path.join(root, "home", "parameters.json"), + {"Design": "gcd", "frequency_max": 50.0}, + ) + # Every successful step emits a minimal valid payload unless a test + # overwrites it; the loader treats a missing payload as a parse failure. + for step_value, state in steps.items(): + if state == SUCCESS: + _write_step_payload(root, _STEP_DIRECTORIES[step_value], [_metric("probe_metric", 1.0)]) + + class _Flow: + data = {} + + return SimpleNamespace( + directory=root, name="gcd", design=SimpleNamespace(name="gcd"), flow=_Flow() + ) + + +SUCCESS = "Success" + +_FULL_FLOW = { + "Synthesis": SUCCESS, + "Floorplan": SUCCESS, + "place": SUCCESS, + "CTS": SUCCESS, + "route": SUCCESS, + "drc": SUCCESS, + "lvs": SUCCESS, + "RCX": SUCCESS, + "sta": SUCCESS, + "Harden": SUCCESS, +} + + +class TestMetricSelection: + def test_role_priority_final_beats_trend(self, tmp_path): + workspace = _make_workspace(tmp_path, _FULL_FLOW) + _write_step_payload( + workspace.directory, + "place_ecc".replace("place_ecc", "place_dreamplace"), + [_metric("place_hpwl", 100.0, project_role="trend")], + ) + _write_step_payload( + workspace.directory, + "route_ecc", + [_metric("place_hpwl", 200.0, project_role="final")], + ) + inputs = load_workspace_qor_inputs(workspace) + assert inputs.value("place_hpwl") == 200.0 + + def test_later_step_wins_within_same_role(self, tmp_path): + workspace = _make_workspace(tmp_path, _FULL_FLOW) + _write_step_payload( + workspace.directory, + "place_dreamplace", + [_metric("place_hpwl", 100.0, project_role="trend")], + ) + _write_step_payload( + workspace.directory, + "route_ecc", + [_metric("place_hpwl", 111.0, project_role="trend")], + ) + inputs = load_workspace_qor_inputs(workspace) + assert inputs.value("place_hpwl") == 111.0 + assert inputs.metrics["place_hpwl"].step == "route" + + def test_stale_payload_of_unstarted_step_is_ignored(self, tmp_path): + steps = dict(_FULL_FLOW) + steps["route"] = "Unstart" + workspace = _make_workspace(tmp_path, steps) + _write_step_payload(workspace.directory, "route_ecc", [_metric("route_wirelength", 9999.0)]) + inputs = load_workspace_qor_inputs(workspace) + assert inputs.value("route_wirelength") is None + assert "route" not in inputs.analyzed_steps + assert inputs.parse_failures == 0 # unstarted steps are not failures + + def test_failed_payload_counts_as_parse_failure(self, tmp_path): + workspace = _make_workspace(tmp_path, _FULL_FLOW) + _write( + os.path.join(workspace.directory, "drc_ecc", "analysis", "qor_metrics.json"), + {"broken": True}, + ) + inputs = load_workspace_qor_inputs(workspace) + assert inputs.parse_failures == 1 + assert "drc" not in inputs.analyzed_steps + + +class TestParameterResolution: + def test_frequency_max_resolves_tclk(self, tmp_path): + workspace = _make_workspace(tmp_path, _FULL_FLOW) + inputs = load_workspace_qor_inputs(workspace) + assert inputs.tclk_ns == pytest.approx(20.0) + assert inputs.config_warnings == [] + + def test_unknown_profile_warns_and_falls_back(self, tmp_path): + workspace = _make_workspace(tmp_path, _FULL_FLOW) + _write( + os.path.join(workspace.directory, "home", "parameters.json"), + {"frequency_max": 50.0, "qor_profile": "speed_demon"}, + ) + inputs = load_workspace_qor_inputs(workspace) + assert inputs.profile == "balanced" + assert inputs.config_warnings + + def test_nonpositive_budget_warns_and_undeclares(self, tmp_path): + workspace = _make_workspace(tmp_path, _FULL_FLOW) + _write( + os.path.join(workspace.directory, "home", "parameters.json"), + {"frequency_max": 50.0, "qor_power_budget_w": -1}, + ) + inputs = load_workspace_qor_inputs(workspace) + assert inputs.power_budget_uw is None + assert inputs.config_warnings + + +class TestCornerLoading: + def test_per_corner_summaries_are_parsed(self, tmp_path): + workspace = _make_workspace(tmp_path, _FULL_FLOW) + for corner_dir, wns in (("MAX_125_t125", 16.622), ("MAX_M40_tm40", 18.98)): + _write( + os.path.join( + workspace.directory, + "sta_ecc", + "feature", + corner_dir, + "Cworst", + "qor_summary.json", + ), + { + "path_groups": [], + "summary": { + "setup": {"wns": wns, "tns": 0.0, "nvp": 0, "frequency_mhz": 296.0}, + "hold": {"wns": 0.1, "tns": 0.0, "nvp": 0}, + }, + }, + ) + inputs = load_workspace_qor_inputs(workspace) + assert [corner.setup_ws for corner in inputs.corners] == [16.622, 18.98] + + +class TestDesignResolution: + def test_design_falls_back_to_parameters(self, tmp_path): + workspace = _make_workspace(tmp_path, _FULL_FLOW) + workspace.design = SimpleNamespace(name="") + workspace.name = "" + inputs = load_workspace_qor_inputs(workspace) + assert inputs.design == "gcd" diff --git a/test/analysis/qor/test_reference_gcd.py b/test/analysis/qor/test_reference_gcd.py new file mode 100644 index 000000000..43dd51fbf --- /dev/null +++ b/test/analysis/qor/test_reference_gcd.py @@ -0,0 +1,238 @@ +"""End-to-end regression against the reference GCD fixture (spec §12.1). + +The spec's headline numbers assume route-side compatibility, which the +D-C ladder downgrades to the I_place path until the toolchain emits net +mappings. This test pins the ladder values end-to-end, and pins the +spec's own I_total-based numbers on the calibration layer (see +test_scoring.test_spec_reference_weighted_mean_formula). +""" + +import json +import os +from types import SimpleNamespace + +import pytest + +from chipcompiler.analysis.qor import ( + build_qor_analysis, + refresh_workspace_qor_report, + render_qor_analysis, +) +from chipcompiler.analysis.qor.loader import load_workspace_qor_inputs +from chipcompiler.analysis.qor.schema import validate_report + +SUCCESS = "Success" + +_FLOW_STEPS = { + "Synthesis": SUCCESS, + "Floorplan": SUCCESS, + "place": SUCCESS, + "CTS": SUCCESS, + "route": SUCCESS, + "drc": SUCCESS, + "lvs": SUCCESS, + "RCX": SUCCESS, + "sta": SUCCESS, + "Harden": SUCCESS, +} + + +def _write(path, payload): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + json.dump(payload, f) + + +def _metric(metric_id, value, project_role="final", unit=""): + return { + "id": metric_id, + "display_name": metric_id, + "value": value, + "unit": unit, + "category": "timing", + "direction": "lower_is_better", + "scope": "project", + "corner": None, + "project_role": project_role, + "step_role": "primary", + "rating": {"gate": False, "score": True, "trend": True}, + "source": {"kind": "analysis", "path": "feature", "selector": "/x"}, + } + + +def _payload(metrics): + return { + "schema_version": 3, + "kind": "qor_metrics", + "integrity": {"status": "pass", "invalid_metric_source_ids": [], "invalid_detail_ids": []}, + "metrics": metrics, + } + + +def _make_gcd_workspace(tmp_path): + root = str(tmp_path / "ws") + _write( + os.path.join(root, "home", "flow.json"), + { + "steps": [ + {"name": name, "tool": "ecc", "state": state} for name, state in _FLOW_STEPS.items() + ] + }, + ) + _write( + os.path.join(root, "home", "parameters.json"), + {"Design": "gcd", "frequency_max": 50.0}, + ) + + directory_by_step = { + "Synthesis": "Synthesis_yosys", + "Floorplan": "Floorplan_ecc", + "place": "place_dreamplace", + "CTS": "CTS_ecc", + "route": "route_ecc", + "drc": "drc_ecc", + "lvs": "lvs_ecc", + "RCX": "RCX_ecc", + "sta": "sta_ecc", + "Harden": "Harden_ecc", + } + payloads = { + "Synthesis": [_metric("synthesis_cell_area", 800.0, "trend", "um^2")], + "Floorplan": [ + _metric("core_area", 1538.46, "trend", "um^2"), + _metric("core_utilization", 0.52, "trend"), + ], + "place": [ + _metric("place_hpwl", 3143.52, "trend", "um"), + _metric("place_grwl", 3812.00, "trend", "um"), + _metric("place_rudy_utilization_max", 0.0, "trend"), + ], + "CTS": [ + _metric("cts_buffer_count", 4, "trend"), + _metric("cts_inverter_count", 0, "trend"), + _metric("clock_path_max_buffer", 4, "trend"), + _metric("clock_path_min_buffer", 4, "trend"), + ], + "route": [ + _metric("route_wirelength", 4315.53, "final", "um"), + _metric("route_via_count", 608, "final"), + ], + "drc": [_metric("drc_count", 0, "gate")], + "lvs": [_metric("lvs_count", 0, "gate")], + "RCX": [ + _metric("rcx_spef_file_count", 2, "gate"), + _metric("rcx_expected_corner_count", 2, "trend"), + _metric("rcx_missing_corner_count", 0, "gate"), + ], + "sta": [ + _metric("sta_setup_wns", 16.622, "gate", "ns"), + _metric("sta_setup_tns", 0.0, "gate", "ns"), + _metric("sta_hold_wns", 0.1, "gate", "ns"), + _metric("sta_hold_tns", 0.0, "gate", "ns"), + _metric("sta_setup_violation_count", 0, "gate"), + _metric("sta_hold_violation_count", 0, "gate"), + _metric("sta_expected_corner_count", 2, "trend"), + _metric("sta_missing_corner_count", 0, "gate"), + ], + "Harden": [_metric("harden_artifact_missing_count", 0, "final")], + } + for step_value, metrics in payloads.items(): + _write( + os.path.join(root, directory_by_step[step_value], "analysis", "qor_metrics.json"), + _payload(metrics), + ) + + for corner_dir, setup_wns, hold_wns in ( + ("MAX_125_t125", 16.622, 0.100), + ("MAX_M40_tm40", 18.980, 0.274), + ): + _write( + os.path.join(root, "sta_ecc", "feature", corner_dir, "Cworst", "qor_summary.json"), + { + "path_groups": [], + "summary": { + "setup": {"wns": setup_wns, "tns": 0.0, "nvp": 0, "frequency_mhz": 296.0}, + "hold": {"wns": hold_wns, "tns": 0.0, "nvp": 0}, + }, + }, + ) + + class _Flow: + data = {} + + return SimpleNamespace( + directory=root, name="gcd", design=SimpleNamespace(name="gcd"), flow=_Flow() + ) + + +class TestReferenceGcd: + @pytest.fixture + def analysis(self, tmp_path): + return build_qor_analysis(_make_gcd_workspace(tmp_path)) + + def test_feasibility_passes_all_gates(self, analysis): + assert analysis.feasibility.status == "PASS" + assert all(gate.state == "passed" for gate in analysis.feasibility.gates) + assert len(analysis.feasibility.gates) == 7 + + def test_evidence_is_high(self, analysis): + assert analysis.evidence.state == "HIGH" + assert analysis.evidence.index == pytest.approx(100.0) + + def test_qt_spec_value(self, analysis): + # WS = +16.622ns >= tau_guardband (0.05*20ns) -> QT = 100, OPPORTUNITY. + assert analysis.qor_record["timing"].value == pytest.approx(100.0) + assert analysis.qor_record["timing"].state == "OPPORTUNITY" + + def test_qi_place_ladder_value(self, analysis): + # I_place = 3812/3143.52 = 1.2127 <= tau_pref = 1.25, S_cong = 0. + assert analysis.inflation.i_place == pytest.approx(1.2127, abs=0.001) + assert analysis.qor_record["interconnect"].value == pytest.approx(100.0) + + def test_route_side_stays_unknown_under_dc_ladder(self, analysis): + assert analysis.inflation.i_route is None + assert analysis.inflation.i_total is None + assert analysis.inflation.compatibility_status == "INCOMPATIBLE" + + def test_qa_and_qp_spec_values(self, analysis): + assert analysis.qor_record["area"].value == pytest.approx(100.0) + assert analysis.qor_record["power"].value is None + + def test_qr_spec_value(self, analysis): + assert analysis.qor_record["robustness"].value == pytest.approx(94.105, abs=0.01) + + def test_scalar_summary_ladder_value(self, analysis): + # (0.30*100 + 0.25*100 + 0.15*100 + 0.15*94.105) / 0.85 = 98.96. + assert analysis.scalar_summary.score == pytest.approx(98.96, abs=0.01) + assert analysis.scalar_summary.status == "GREEN" + + def test_over_provision_diagnosis_present(self, analysis): + over = next( + d for d in analysis.diagnoses if d.diagnosis_id == "diag.timing.over_provisioned" + ) + assert over.state == "OPPORTUNITY" + assert over.severity == pytest.approx(0.789, abs=0.001) + + def test_report_persists_and_validates(self, tmp_path): + workspace = _make_gcd_workspace(tmp_path) + destination = refresh_workspace_qor_report(workspace) + assert destination.name == "qor_report.json" + with open(destination) as f: + payload = json.load(f) + assert payload["schema_version"] == 3 + assert payload["scoring_engine"] == "qor-v3" + assert validate_report(payload) == [] + assert payload["flow_steps"]["sta"] == "Success" + + def test_renderer_matches_spec_layout(self, tmp_path): + workspace = _make_gcd_workspace(tmp_path) + analysis = build_qor_analysis(workspace) + text = render_qor_analysis(analysis, load_workspace_qor_inputs(workspace)) + assert "ECC QoR ANALYSIS REPORT" in text + assert "FEASIBILITY STATUS : PASS [All 7 Physical Signoff Gates Clean]" in text + assert "EVIDENCE STATE : HIGH" in text + assert "Status: GREEN, Profile: balanced" in text + assert "Q_T" in text + assert "WS: +16.622ns" in text + assert "[PRIORITIZED INTERVENTION HYPOTHESES]" in text + assert "diag.timing.over_provisioned" in text diff --git a/test/analysis/qor/test_schema.py b/test/analysis/qor/test_schema.py new file mode 100644 index 000000000..cd91ec485 --- /dev/null +++ b/test/analysis/qor/test_schema.py @@ -0,0 +1,55 @@ +from chipcompiler.analysis.qor import build_qor_analysis +from chipcompiler.analysis.qor.schema import validate_report +from test.analysis.qor.helpers import gcd_corners, gcd_metrics, make_inputs + + +class TestSchemaParity: + def test_assembled_report_validates(self): + from unittest import mock + + inputs = make_inputs(gcd_metrics(), corners=gcd_corners(), tclk_ns=20.0) + with mock.patch( + "chipcompiler.analysis.qor.load_workspace_qor_inputs", + lambda workspace: inputs, + ): + analysis = build_qor_analysis(workspace=None) + assert validate_report(analysis.to_dict()) == [] + + def test_missing_required_key_is_detected(self): + from unittest import mock + + inputs = make_inputs(gcd_metrics(), corners=gcd_corners(), tclk_ns=20.0) + with mock.patch( + "chipcompiler.analysis.qor.load_workspace_qor_inputs", + lambda workspace: inputs, + ): + payload = build_qor_analysis(workspace=None).to_dict() + del payload["evidence"] + errors = validate_report(payload) + assert any("evidence" in error for error in errors) + + def test_out_of_range_score_is_detected(self): + from unittest import mock + + inputs = make_inputs(gcd_metrics(), corners=gcd_corners(), tclk_ns=20.0) + with mock.patch( + "chipcompiler.analysis.qor.load_workspace_qor_inputs", + lambda workspace: inputs, + ): + payload = build_qor_analysis(workspace=None).to_dict() + payload["scalar_summary"]["score"] = 150.0 + errors = validate_report(payload) + assert any("score out of range" in error for error in errors) + + def test_invalid_gate_state_is_detected(self): + from unittest import mock + + inputs = make_inputs(gcd_metrics(), corners=gcd_corners(), tclk_ns=20.0) + with mock.patch( + "chipcompiler.analysis.qor.load_workspace_qor_inputs", + lambda workspace: inputs, + ): + payload = build_qor_analysis(workspace=None).to_dict() + payload["feasibility"]["gates"][0]["state"] = "maybe" + errors = validate_report(payload) + assert any("gate state invalid" in error for error in errors) diff --git a/test/analysis/qor/test_scoring.py b/test/analysis/qor/test_scoring.py new file mode 100644 index 000000000..20a4b872a --- /dev/null +++ b/test/analysis/qor/test_scoring.py @@ -0,0 +1,84 @@ +import pytest + +from chipcompiler.analysis.qor.dimensions import evaluate_dimensions +from chipcompiler.analysis.qor.feasibility import evaluate_feasibility +from chipcompiler.analysis.qor.features import compute_features +from chipcompiler.analysis.qor.scoring import PROFILES, evaluate_scalar_summary +from test.analysis.qor.helpers import gcd_corners, gcd_metrics, make_inputs + + +def _summary(inputs): + bundle = compute_features(inputs) + dimensions = evaluate_dimensions(bundle, inputs) + feasibility = evaluate_feasibility(inputs) + return evaluate_scalar_summary(feasibility, dimensions, inputs.profile), dimensions + + +class TestScalarSummary: + def test_spec_reference_gcd_projection(self): + # Spec §12.1 with the D-C place ladder: all four evaluated dims + # renormalize over weights (0.30+0.25+0.15+0.15)/0.85. + summary, _ = _summary(make_inputs(gcd_metrics(), corners=gcd_corners(), tclk_ns=20.0)) + assert summary.score == pytest.approx(98.96, abs=0.01) + assert summary.status == "GREEN" + + def test_spec_reference_weighted_mean_formula(self): + # Pin the documented §12.1 weighted-mean formula itself: + # (0.30*100 + 0.25*75.434 + 0.15*100 + 0.15*94.105) / 0.85 = 91.7. + weights = PROFILES["balanced"] + score = ( + weights["timing"] * 100.0 + + weights["interconnect"] * 75.434 + + weights["area"] * 100.0 + + weights["robustness"] * 94.105 + ) / (weights["timing"] + weights["interconnect"] + weights["area"] + weights["robustness"]) + assert score == pytest.approx(91.7, abs=0.05) + + def test_physical_fail_vetoes_to_zero(self): + metrics = gcd_metrics() + metrics["drc_count"].value = 1 + summary, dimensions = _summary(make_inputs(metrics, tclk_ns=20.0)) + assert summary.score == 0.0 + assert summary.status == "FAIL" + # Excellent other dimensions cannot mask the failure. + assert dimensions["area"].value == pytest.approx(100.0) + + def test_not_verified_is_not_rated(self): + states = {step: "Success" for step in ("Synthesis", "Floorplan", "place", "CTS", "route")} + states["drc"] = "Unstart" + summary, _ = _summary(make_inputs(gcd_metrics(), states)) + assert summary.score is None + assert summary.status == "NOT_RATED" + + def test_missing_dimensions_renormalize(self): + # Robustness (no corners, no CTS depths) and power (no budget) + # evaluate to null while all signoff gates pass; the remaining + # weights renormalize instead of compressing the score scale. + metrics = { + metric_id: record + for metric_id, record in gcd_metrics().items() + if not metric_id.startswith(("clock_path_", "cts_buffer", "cts_inverter")) + } + summary, _ = _summary(make_inputs(metrics, tclk_ns=20.0)) + assert summary.score == pytest.approx(100.0) + assert summary.status == "GREEN" + + def test_profile_weights_change_ranking(self): + base = make_inputs(gcd_metrics(), corners=gcd_corners(), tclk_ns=20.0) + bundle = compute_features(base) + dimensions = evaluate_dimensions(bundle, base) + feasibility = evaluate_feasibility(base) + balanced = evaluate_scalar_summary(feasibility, dimensions, "balanced").score + critical = evaluate_scalar_summary(feasibility, dimensions, "timing_critical").score + # All-evaluated-dimensions-near-100 keeps both high; timing_critical + # weights its 100-score timing dimension higher than balanced does. + assert critical >= balanced + + def test_unknown_profile_falls_back_to_balanced(self): + inputs = make_inputs(gcd_metrics(), corners=gcd_corners(), tclk_ns=20.0) + bundle = compute_features(inputs) + dimensions = evaluate_dimensions(bundle, inputs) + feasibility = evaluate_feasibility(inputs) + summary = evaluate_scalar_summary(feasibility, dimensions, "nonexistent") + assert summary.profile == "nonexistent" + assert summary.weights == PROFILES["balanced"] diff --git a/test/cli/commands/test_report.py b/test/cli/commands/test_report.py index b29ef133c..e7946b945 100644 --- a/test/cli/commands/test_report.py +++ b/test/cli/commands/test_report.py @@ -19,17 +19,16 @@ def report_mocks(monkeypatch): ) monkeypatch.setattr("chipcompiler.data.load_workspace", lambda _path: workspace) - from chipcompiler.engine.qor_report import QorDimensionScore, QorScoreReport + from types import SimpleNamespace as NS - qor_report = QorScoreReport( + qor_report = NS( workspace="/tmp/ws", design="gcd", overall_score=72.5, - status="Green", - gate_status="pass", - area_scoring_step="Harden", + scalar_summary=NS(score=72.5, status="GREEN", profile="balanced", weights={}), + feasibility=NS(status="PASS", gates=[]), dimension_scores=[ - QorDimensionScore("timing", "Timing", 0.35, 80.0, 2), + NS(key="timing", value=80.0, state="PASS", features=[NS(), NS()]), ], ) @@ -98,7 +97,7 @@ def test_qor_writes_default_destination( assert record["report"] == "qor" assert record["status"] == "written" assert record["overall_score"] == "72.5" - assert ast.literal_eval(record["dimensions"])[0]["dimension"] == "Timing" + assert ast.literal_eval(record["dimensions"])[0]["dimension"] == "timing" expected = os.path.join(run_dir, "signoff", "gcd_qor_report.txt") assert record["path"] == expected with open(expected) as f: diff --git a/test/test_qor_report.py b/test/test_qor_report.py index c14c3f1cc..150abd462 100644 --- a/test/test_qor_report.py +++ b/test/test_qor_report.py @@ -2,95 +2,40 @@ import os from types import SimpleNamespace -from chipcompiler.engine.qor_report import ( - QorMetricRecord, - build_qor_report, - generate_qor_report, - score_record, -) +import pytest + +from chipcompiler.engine.qor_report import build_qor_report, generate_qor_report from chipcompiler.engine.signoff.report_checklist import ( build_checklist_report, generate_checklist_report, ) -def _record(**overrides): - base = dict( - step="STA", - metric_name="sta_setup_wns", - display_name="STA Setup WNS", - value=-0.1, - unit="ns", - dimension="timing", - polarity="higher_is_better", - scope="all_configured_corners", - project_role="final", - rating_score=True, - ) - base.update(overrides) - return QorMetricRecord(**base) - - -class TestScoreRecord: - def test_slack_metrics(self): - assert score_record(_record(metric_name="sta_setup_wns", value=0.05)) == 100.0 - assert score_record(_record(metric_name="sta_setup_wns", value=-0.1)) == 50.0 - assert score_record(_record(metric_name="sta_setup_wns", value=-0.2)) == 0.0 - assert score_record(_record(metric_name="sta_setup_wns", value=-0.5)) == 0.0 # clamped - - def test_lower_is_better(self): - assert ( - score_record( - _record( - metric_name="drc_count", - value=0, - dimension="routability_physical", - polarity="lower_is_better", - ) - ) - == 100.0 - ) - assert ( - score_record( - _record( - metric_name="drc_count", - value=5, - dimension="routability_physical", - polarity="lower_is_better", - ) - ) - == 50.0 - ) - - def test_target_range_core_utilization(self): - record = _record( - metric_name="core_utilization", - value=0.55, - dimension="area_cost", - polarity="target_range", - step="Harden", - ) - assert score_record(record) == 100.0 - low = _record( - metric_name="core_utilization", - value=0.225, - dimension="area_cost", - polarity="target_range", - step="Harden", - ) - assert score_record(low) == 50.0 - - def test_trend_only_and_unknown_are_not_scored(self): - assert score_record(_record(polarity="trend_only")) is None - assert score_record(_record(metric_name="not_a_scored_metric")) is None - - def _write(path, payload): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as f: json.dump(payload, f) +def _metric(metric_id, value, project_role="final", **overrides): + record = { + "id": metric_id, + "display_name": metric_id, + "value": value, + "unit": "", + "category": "timing", + "direction": "lower_is_better", + "scope": "project", + "corner": None, + "project_role": project_role, + "step_role": "primary", + "rating": {"gate": False, "score": True, "trend": True}, + "source": {"kind": "analysis", "path": "x"}, + } + record.update(overrides) + return record + + def _metrics_payload(metrics): return { "schema_version": 3, @@ -99,30 +44,16 @@ def _metrics_payload(metrics): } -def _metric(metric_id, value, **overrides): - base = dict( - id=metric_id, - display_name=metric_id, - value=value, - unit="", - category="timing", - direction="lower_is_better", - scope="project", - corner=None, - project_role="final", - step_role="primary", - rating={"gate": False, "score": True, "trend": True}, - ) - base.update(overrides) - return base - - def _make_workspace(tmp_path, *, with_metrics=True, with_checklist=True): root = tmp_path / "ws" _write( root / "home" / "flow.json", { "steps": [ + {"name": "Synthesis", "tool": "yosys", "state": "Success"}, + {"name": "Floorplan", "tool": "ecc", "state": "Success"}, + {"name": "place", "tool": "dreamplace", "state": "Success"}, + {"name": "CTS", "tool": "ecc", "state": "Success"}, {"name": "route", "tool": "ecc", "state": "Success"}, {"name": "drc", "tool": "ecc", "state": "Success"}, {"name": "lvs", "tool": "ecc", "state": "Success"}, @@ -132,14 +63,23 @@ def _make_workspace(tmp_path, *, with_metrics=True, with_checklist=True): ] }, ) - _write(root / "home" / "parameters.json", {"Design": "gcd", "PDK": "ics55"}) + _write(root / "home" / "parameters.json", {"Design": "gcd", "frequency_max": 50.0}) if with_metrics: _write( root / "drc_ecc" / "analysis" / "qor_metrics.json", + _metrics_payload([_metric("drc_count", 0, "gate")]), + ) + _write( + root / "lvs_ecc" / "analysis" / "qor_metrics.json", + _metrics_payload([_metric("lvs_count", 0, "gate")]), + ) + _write( + root / "RCX_ecc" / "analysis" / "qor_metrics.json", _metrics_payload( [ - _metric("drc_count", 0, category="routability_physical"), - _metric("route_dr_total_violation_count", 10, category="routability_physical"), + _metric("rcx_spef_file_count", 1, "gate"), + _metric("rcx_expected_corner_count", 1, "trend"), + _metric("rcx_missing_corner_count", 0, "gate"), ] ), ) @@ -147,27 +87,20 @@ def _make_workspace(tmp_path, *, with_metrics=True, with_checklist=True): root / "sta_ecc" / "analysis" / "qor_metrics.json", _metrics_payload( [ - _metric("sta_setup_wns", -0.1, direction="higher_is_better", unit="ns"), - _metric( - "sta_setup_wns", - -0.1, - direction="higher_is_better", - unit="ns", - corner="MAX_125", - ), + _metric("sta_setup_wns", 0.05, "gate", unit="ns"), + _metric("sta_setup_tns", 0.0, "gate", unit="ns"), + _metric("sta_hold_wns", 0.02, "gate", unit="ns"), + _metric("sta_hold_tns", 0.0, "gate", unit="ns"), + _metric("sta_setup_violation_count", 0, "gate"), + _metric("sta_hold_violation_count", 0, "gate"), + _metric("sta_expected_corner_count", 1, "trend"), + _metric("sta_missing_corner_count", 0, "gate"), ] ), ) _write( root / "Harden_ecc" / "analysis" / "qor_metrics.json", - _metrics_payload( - [ - _metric( - "core_utilization", 0.55, category="area_cost", direction="target_range" - ), - _metric("die_area", 1500, category="area_cost"), - ] - ), + _metrics_payload([_metric("harden_artifact_missing_count", 0, "final")]), ) if with_checklist: _write( @@ -238,63 +171,47 @@ class _Flow: def __init__(self, data): self.data = data - class _Workspace: - directory = str(root) - name = "gcd" - design = type("D", (), {"name": "gcd"})() - with open(root / "home" / "flow.json") as f: - flow_data = json.load(f) - flow = _Flow(flow_data) + with open(root / "home" / "flow.json") as f: + flow_data = json.load(f) - return _Workspace() + return SimpleNamespace( + directory=str(root), + name="gcd", + design=SimpleNamespace(name="gcd"), + flow=_Flow(flow_data), + ) class TestBuildQorReport: - def test_dimension_scores_and_weighted_overall(self, tmp_path): + def test_clean_workspace_rates_continuous_quality(self, tmp_path): + # This fixture carries signoff-passing metrics only: timing is the + # sole evaluated quality coordinate. WS=+0.05ns of a 20ns clock + # reaches the 1ns guardband, so QT = 52.5 and the projection is + # RED — honest, since no area/interconnect/robustness evidence + # exists and weights renormalize over {timing} alone. report = build_qor_report(_make_workspace(tmp_path)) - by_label = {d.label: d for d in report.dimension_scores} - # routability: drc_count=0 -> 100, route_drc=10/50 -> 80 => avg 90 - assert by_label["Routability / Physical"].score == 90.0 - # timing: sta_setup_wns -0.1 -> 50 (both corner records dedup? no: - # different corners are distinct keys, both 50) => 50 - assert by_label["Timing"].score == 50.0 - # area: utilization 100, die_area 1500/3000 -> 50 => 75 - assert by_label["Area"].score == 75.0 - # GUI rule: weights are NOT renormalized over missing dimensions. - assert report.overall_score == round(50.0 * 0.35 + 90.0 * 0.2 + 75.0 * 0.1, 1) - assert report.status == "Green" - assert report.gate_status == "pass" - assert report.area_scoring_step == "Harden" - - def test_corner_records_are_distinct(self, tmp_path): + assert report.feasibility.status == "PASS" + by_key = {dim.key: dim for dim in report.dimension_scores} + assert by_key["timing"].value == pytest.approx(52.5) + assert by_key["timing"].state == "WATCH" + assert report.overall_score == pytest.approx(52.5) + assert report.scalar_summary.status == "RED" + + def test_dimension_scores_carry_the_five_coordinates(self, tmp_path): report = build_qor_report(_make_workspace(tmp_path)) - sta_rows = [m for m in report.metrics if m.metric_name == "sta_setup_wns"] - assert len(sta_rows) == 2 - - def test_trend_only_records_are_not_selected_for_score(self, tmp_path): - workspace = _make_workspace(tmp_path) - _write( - os.path.join(workspace.directory, "drc_ecc", "analysis", "qor_metrics.json"), - _metrics_payload( - [ - _metric("drc_count", 0, category="routability_physical"), - _metric( - "drc_count", - 7, - category="routability_physical", - project_role="trend", - rating={"gate": False, "score": False, "trend": True}, - ), - ] - ), - ) - report = build_qor_report(workspace) - by_label = {d.label: d for d in report.dimension_scores} - assert by_label["Routability / Physical"].metric_count == 1 + assert {dim.key for dim in report.dimension_scores} == { + "timing", + "interconnect", + "area", + "power", + "robustness", + } + # No placed area or power budget in this fixture: they stay UNKNOWN. + by_key = {dim.key: dim for dim in report.dimension_scores} + assert by_key["power"].value is None + assert by_key["power"].state == "UNKNOWN" def test_stale_metrics_of_unstarted_steps_do_not_score(self, tmp_path): - # Invalidation resets a step to Unstart but keeps its analysis - # outputs on disk; the obsolete metrics must not score. workspace = _make_workspace(tmp_path) for step in workspace.flow.data["steps"]: if step["name"] == "drc": @@ -302,17 +219,43 @@ def test_stale_metrics_of_unstarted_steps_do_not_score(self, tmp_path): report = build_qor_report(workspace) - assert [m for m in report.metrics if m.step == "DRC"] == [] - by_label = {d.label: d for d in report.dimension_scores} - assert "Routability / Physical" not in by_label + assert report.feasibility.status == "NOT_VERIFIED" + drc_gate = next(g for g in report.feasibility.gates if g.id == "GATE_DRC") + assert drc_gate.state == "unavailable" + assert drc_gate.availability == "not_verified" + + def test_signoff_failure_vetoes_the_score(self, tmp_path): + workspace = _make_workspace(tmp_path) + _write( + os.path.join(workspace.directory, "drc_ecc", "analysis", "qor_metrics.json"), + _metrics_payload([_metric("drc_count", 2, "gate")]), + ) + report = build_qor_report(workspace) + assert report.feasibility.status == "PHYSICAL_FAIL" + assert report.overall_score == 0.0 + assert report.scalar_summary.status == "FAIL" def test_empty_workspace_report(self, tmp_path): + # Steps completed per the ledger but no payloads survived: that is + # corrupt/missing evidence (UNKNOWN), not an omitted verification. report = build_qor_report(_make_workspace(tmp_path, with_metrics=False)) assert report.overall_score is None - assert report.status == "Blocked" + assert report.scalar_summary.status == "NOT_RATED" + assert report.feasibility.status == "UNKNOWN" text = generate_qor_report(_make_workspace(tmp_path, with_metrics=False)) - assert "NOT RATED" in text - assert "no project-level QoR metrics available" in text + assert "NOT_RATED" in text + assert "UNKNOWN" in text + + def test_missing_evidence_of_completed_stage_is_unknown(self, tmp_path): + workspace = _make_workspace(tmp_path) + _write( + os.path.join(workspace.directory, "lvs_ecc", "analysis", "qor_metrics.json"), + {"broken": True}, + ) + report = build_qor_report(workspace) + assert report.feasibility.status == "UNKNOWN" + lvs_gate = next(g for g in report.feasibility.gates if g.id == "GATE_LVS") + assert lvs_gate.availability == "corrupt" def test_uses_loaded_workspace_parameters(self, tmp_path): workspace = _make_workspace(tmp_path) @@ -324,72 +267,13 @@ def test_uses_loaded_workspace_parameters(self, tmp_path): def test_text_report_layout(self, tmp_path): text = generate_qor_report(_make_workspace(tmp_path)) - assert "ECC QOR OVERALL SCORE" in text - assert "[ DIMENSION SCORES ]" in text - assert "[ METRIC SCORES ]" in text - assert "sta_setup_wns" in text - assert "END OF QOR REPORT" in text - assert "weights not renormalized" in text - - -class TestFlowStepOrder: - def test_flow_steps_follow_the_canonical_chain_order(self): - from chipcompiler.engine.qor_report import FLOW_STEPS - - assert FLOW_STEPS == ( - "Synth", - "PostFloorplan", - "Place", - "CTS", - "Legal", - "Route", - "Filler", - "RCX", - "STA", - "LVS", - "DRC", - "Harden", - ) - - def test_area_scoring_uses_the_latest_scored_step_in_chain_order(self): - from chipcompiler.engine.qor_report import QorMetricRecord, _resolve_area_scoring_step - - def record(step): - return QorMetricRecord( - step=step, - metric_name="die_area", - display_name="die_area", - value=1.0, - dimension="area_cost", - rating_score=True, - ) - - flow_states = {"STA": "Success", "DRC": "Success"} - assert _resolve_area_scoring_step([record("STA"), record("DRC")], flow_states) == "DRC" - - -class TestFlowCompletionState: - def test_states_are_derived_explicitly(self): - from chipcompiler.engine.qor_report import _flow_completion_state - - assert _flow_completion_state([]) == "not_started" - assert _flow_completion_state(["Unstart"]) == "not_started" - assert _flow_completion_state(["Success", "Ongoing"]) == "running" - assert _flow_completion_state(["Success", "Unstart"]) == "in_progress" - assert _flow_completion_state(["Success", "Incomplete"]) == "failed" - assert _flow_completion_state(["Invalid"]) == "failed" - assert _flow_completion_state(["Success"] * 5) == "complete" - # A legacy persisted Warning (removed terminal state) is unfinished. - assert _flow_completion_state(["Success", "Warning"]) == "in_progress" - - def test_nonterminal_workspaces_are_blocked(self, tmp_path): - for state in ("Ongoing", "Unstart", "Pending"): - workspace = _make_workspace(tmp_path / state, with_metrics=False) - flow = workspace.flow.data - for step in flow["steps"][:3]: - step["state"] = state - report = build_qor_report(workspace) - assert report.status == "Blocked", state + assert "ECC QoR ANALYSIS REPORT" in text + assert "FEASIBILITY STATUS" in text + assert "EVIDENCE STATE" in text + assert "QoR COMPOSITE" in text + assert "[PHYSICAL QoR RECORD BREAKDOWN]" in text + assert "[PRIORITIZED INTERVENTION HYPOTHESES]" in text + assert "(No active feasibility blockers detected)" in text class TestChecklistReport: From c87e0b15d5e9d1455da89838d5fae0b85a44f506 Mon Sep 17 00:00:00 2001 From: Yell-walkalone <12112088@qq.com> Date: Wed, 9 Sep 2026 12:54:54 +0800 Subject: [PATCH 2/7] fix(qor): harden report inputs and STA fallbacks --- chipcompiler/analysis/qor/evidence.py | 27 ++- chipcompiler/analysis/qor/features.py | 25 ++- chipcompiler/analysis/qor/loader.py | 90 ++++++++-- chipcompiler/analysis/qor/schema.py | 244 +++++++++++++++++++------- chipcompiler/engine/flow.py | 8 + chipcompiler/tools/ecc/metrics.py | 4 +- chipcompiler/tools/ecc/sta_qor.py | 27 ++- test/analysis/qor/helpers.py | 1 + test/analysis/qor/test_dimensions.py | 14 ++ test/analysis/qor/test_evidence.py | 18 ++ test/analysis/qor/test_features.py | 23 +++ test/analysis/qor/test_loader.py | 107 +++++++++++ test/analysis/qor/test_schema.py | 16 ++ 13 files changed, 503 insertions(+), 101 deletions(-) create mode 100644 test/analysis/qor/test_features.py diff --git a/chipcompiler/analysis/qor/evidence.py b/chipcompiler/analysis/qor/evidence.py index 8059fdf3e..d81592a34 100644 --- a/chipcompiler/analysis/qor/evidence.py +++ b/chipcompiler/analysis/qor/evidence.py @@ -42,9 +42,12 @@ def evaluate_evidence(inputs, bundle) -> Evidence: index = 100.0 for component in active: index *= component + state = _state(index) + if inputs.sta_setup_only and state == _HIGH: + state = _MODERATE return Evidence( index=index, - state=_state(index), + state=state, integrity=integrity, coverage=coverage, consistency=consistency, @@ -107,9 +110,18 @@ def _consistency(inputs, bundle): # C2: (WS >= 0) <=> (NVP == 0) under one scope — both metrics come # from the same STA payload over the same configured corners. - ws = inputs.value("sta_setup_wns") - nvp = inputs.value("sta_setup_violation_count") - if ws is not None and nvp is not None: + ws_record = inputs.metrics.get("sta_setup_wns") + nvp_record = inputs.metrics.get("sta_setup_violation_count") + if ( + ws_record is not None + and nvp_record is not None + and ws_record.scope is not None + and ws_record.scope == nvp_record.scope + and ws_record.corner == nvp_record.corner + and _population_compatible(ws_record.endpoint_population, nvp_record.endpoint_population) + ): + ws = ws_record.value + nvp = nvp_record.value checks.append((ws >= 0.0) == (nvp == 0)) # C3: vias imply routed wirelength (one-way topological sanity). @@ -121,3 +133,10 @@ def _consistency(inputs, bundle): if not checks: return None return sum(1.0 for passed in checks if passed) / len(checks) + + +def _population_compatible(left, right) -> bool: + """C2 is applicable only when endpoint populations are comparable.""" + if left is None or right is None: + return True + return left == right diff --git a/chipcompiler/analysis/qor/features.py b/chipcompiler/analysis/qor/features.py index ad54004aa..ecbc1bcd0 100644 --- a/chipcompiler/analysis/qor/features.py +++ b/chipcompiler/analysis/qor/features.py @@ -93,7 +93,7 @@ def compute_features(inputs) -> FeatureBundle: leak = inputs.value("synthesis_power_leakage_uw") dynamic = inputs.value("synthesis_power_dynamic_uw") - leak_frac = _ratio(leak, (dynamic + leak) if dynamic is not None and dynamic > 0 else None) + leak_frac = _ratio(leak, dynamic + leak if dynamic is not None and leak is not None else None) _record( f, inputs, @@ -238,15 +238,28 @@ def compute_features(inputs) -> FeatureBundle: "Normalized signed setup slack headroom.", ) + frequency_margin = None + if ws is not None and tclk and tclk - ws > 0: + target_frequency = 1000.0 / tclk + achieved_frequency = 1000.0 / (tclk - ws) + frequency_margin = (achieved_frequency - target_frequency) / target_frequency + _record( + f, + inputs, + "F_STA_FREQ_MARGIN", + frequency_margin, + "OPPORTUNITY" if frequency_margin is not None and frequency_margin >= 0 else "UNKNOWN", + "Derived frequency margin relative to the target clock period.", + ) + setup_ws_values = [corner.setup_ws for corner in inputs.corners] - hold_ws_values = [corner.hold_ws for corner in inputs.corners] - delta_setup = max(setup_ws_values) - min(setup_ws_values) if len(setup_ws_values) >= 2 else None + hold_ws_values = [corner.hold_ws for corner in inputs.corners if corner.hold_ws is not None] + delta_setup = max(setup_ws_values) - min(setup_ws_values) if setup_ws_values else None delta_hold = max(hold_ws_values) - min(hold_ws_values) if len(hold_ws_values) >= 2 else None pvt_setup = _ratio(delta_setup, tclk) if tclk else None pvt_hold = _ratio(delta_hold, tclk) if tclk else None - pvt_max: float | None = None - if pvt_setup is not None and pvt_hold is not None: - pvt_max = max(pvt_setup, pvt_hold) + pvt_values = [value for value in (pvt_setup, pvt_hold) if value is not None] + pvt_max: float | None = max(pvt_values) if pvt_values else None _record( f, inputs, diff --git a/chipcompiler/analysis/qor/loader.py b/chipcompiler/analysis/qor/loader.py index f1d52a85d..d7232f269 100644 --- a/chipcompiler/analysis/qor/loader.py +++ b/chipcompiler/analysis/qor/loader.py @@ -7,13 +7,16 @@ """ import dataclasses +from math import isfinite from pathlib import Path from chipcompiler.analysis.qor.metric_registry import SCORED_STEP_VALUES from chipcompiler.data import StateEnum, StepEnum from chipcompiler.data.step_dirs import STEP_DIRECTORIES from chipcompiler.tools.ecc.sta_qor import ( + POST_SYNTHESIS_STA_CORNER, STA_POWER_SUMMARY_FILENAME, + configured_sta_artifact_directories, read_sta_power_summary_json, read_sta_qor_summary, ) @@ -33,15 +36,17 @@ class MetricRecord: project_role: str corner: str | None source: dict + scope: str | None = None + endpoint_population: float | None = None @dataclasses.dataclass(frozen=True) class CornerSlack: corner: str setup_ws: float - hold_ws: float - setup_nvp: int - hold_nvp: int + hold_ws: float | None + setup_nvp: int | None + hold_nvp: int | None @dataclasses.dataclass @@ -58,6 +63,8 @@ class QorInputs: rcx_spef_count: float | None = None rcx_expected_spef: float | None = None power_total_uw: float | None = None + power_source_path: str | None = None + sta_setup_only: bool = False tclk_ns: float | None = None profile: str = "balanced" power_budget_uw: float | None = None @@ -119,7 +126,11 @@ def _select_metrics(step_payloads: list) -> dict: value = raw.get("value") if not isinstance(metric_id, str) or not metric_id: continue - if isinstance(value, bool) or not isinstance(value, (int, float)): + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not isfinite(value) + ): continue role = raw.get("project_role") if role not in _ROLE_PRIORITY or role == "none": @@ -132,6 +143,13 @@ def _select_metrics(step_payloads: list) -> dict: project_role=role, corner=raw.get("corner") if isinstance(raw.get("corner"), str) else None, source=raw.get("source") if isinstance(raw.get("source"), dict) else {}, + scope=raw.get("scope") if isinstance(raw.get("scope"), str) else None, + endpoint_population=( + float(raw["endpoint_population"]) + if isinstance(raw.get("endpoint_population"), (int, float)) + and not isinstance(raw.get("endpoint_population"), bool) + else None + ), ) rank = (_ROLE_PRIORITY[role], -index) current = selected.get(metric_id) @@ -140,15 +158,30 @@ def _select_metrics(step_payloads: list) -> dict: return {metric_id: rank_record[1] for metric_id, rank_record in selected.items()} -def _corner_slack(workspace_root: Path) -> list: +def _corner_slack(workspace, workspace_root: Path, flow_states: dict) -> tuple[list, bool]: + if flow_states.get(StepEnum.STA.value) != StateEnum.Success.value: + return [], False corners = [] feature_root = workspace_root / _STA_FEATURE_DIR if not feature_root.is_dir(): - return corners - for path in sorted(feature_root.glob("*/*/qor_summary.json")): - summary = read_sta_qor_summary(path.parts[-3], path) + return corners, False + + configured = configured_sta_artifact_directories(workspace, feature_root) + if configured: + candidates = [(label, path / "qor_summary.json") for label, path in configured] + else: + # Test stand-ins may omit workspace.config. Real workspaces are always + # constrained by configured STA directories above. + candidates = [ + (path.parts[-3], path) for path in sorted(feature_root.glob("*/*/qor_summary.json")) + ] + + setup_only = False + for corner, path in candidates: + summary = read_sta_qor_summary(corner, path, require_hold=False) if summary is None: continue + setup_only = setup_only or summary.hold_wns is None corners.append( CornerSlack( corner=summary.corner, @@ -158,7 +191,34 @@ def _corner_slack(workspace_root: Path) -> list: hold_nvp=summary.hold_nvp, ) ) - return corners + return corners, setup_only + + +def _power_summary(workspace, workspace_root: Path, flow_states: dict): + """Select the worst available signoff power, with synthesis fallback.""" + if flow_states.get(StepEnum.STA.value) == StateEnum.Success.value: + feature_root = workspace_root / _STA_FEATURE_DIR + totals = [] + for _, directory in configured_sta_artifact_directories(workspace, feature_root): + path = directory / STA_POWER_SUMMARY_FILENAME + summary = read_sta_power_summary_json(path) + if summary is not None: + totals.append((summary.dynamic_uw + summary.leakage_uw, path)) + if totals: + return max(totals, key=lambda item: item[0]) + + if flow_states.get(StepEnum.SYNTHESIS.value) == StateEnum.Success.value: + path = ( + workspace_root + / STEP_DIRECTORIES[StepEnum.SYNTHESIS.value] + / "feature" + / POST_SYNTHESIS_STA_CORNER + / STA_POWER_SUMMARY_FILENAME + ) + summary = read_sta_power_summary_json(path) + if summary is not None: + return summary.dynamic_uw + summary.leakage_uw, path + return None, None def _resolve_parameters(parameters: dict, warnings: list) -> tuple: @@ -228,12 +288,8 @@ def _metric_value(metric_id: str) -> float | None: record = metrics.get(metric_id) return record.value if record is not None else None - power_total_uw = None - power_summary = read_sta_power_summary_json( - workspace_root / _STA_FEATURE_DIR / STA_POWER_SUMMARY_FILENAME - ) - if power_summary is not None: - power_total_uw = power_summary.dynamic_uw + power_summary.leakage_uw + power_total_uw, power_source = _power_summary(workspace, workspace_root, flow_states) + corners, sta_setup_only = _corner_slack(workspace, workspace_root, flow_states) return QorInputs( design=design, @@ -243,11 +299,13 @@ def _metric_value(metric_id: str) -> float | None: analyzed_steps=analyzed_steps, parse_failures=parse_failures, invalid_selector_count=invalid_selector_count, - corners=_corner_slack(workspace_root), + corners=corners, sta_expected_corners=_metric_value("sta_expected_corner_count"), rcx_spef_count=_metric_value("rcx_spef_file_count"), rcx_expected_spef=_metric_value("rcx_expected_corner_count"), power_total_uw=power_total_uw, + power_source_path=str(power_source) if power_source is not None else None, + sta_setup_only=sta_setup_only, tclk_ns=tclk_ns, profile=profile, power_budget_uw=power_budget_uw, diff --git a/chipcompiler/analysis/qor/schema.py b/chipcompiler/analysis/qor/schema.py index 722e148b3..5b91f8add 100644 --- a/chipcompiler/analysis/qor/schema.py +++ b/chipcompiler/analysis/qor/schema.py @@ -7,6 +7,8 @@ loudly in tests instead of silently downstream. """ +from math import isfinite + from chipcompiler.analysis.qor.models import SCHEMA_VERSION FEASIBILITY_STATUSES = ("PASS", "PHYSICAL_FAIL", "NOT_VERIFIED", "UNKNOWN") @@ -76,85 +78,207 @@ def require(condition, message): if errors: return errors + def finite_number(value, *, low=None, high=None, nullable=False): + if value is None and nullable: + return True + if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value): + return False + return (low is None or value >= low) and (high is None or value <= high) + + def string_list(value): + return isinstance(value, list) and all(isinstance(item, str) for item in value) + require(report["schema_version"] == SCHEMA_VERSION, "schema_version must be 3") require(report["scoring_engine"] == "qor-v3", "scoring_engine must be qor-v3") - require(isinstance(report["timestamp"], str), "timestamp must be a string") + for field in ("design", "workspace", "timestamp", "profile"): + require(isinstance(report[field], str), f"{field} must be a string") + require(finite_number(report["tclk_ns"], low=0, nullable=True), "tclk_ns invalid") feasibility = report["feasibility"] require(isinstance(feasibility, dict), "feasibility must be an object") - require( - feasibility.get("status") in FEASIBILITY_STATUSES, - f"feasibility.status invalid: {feasibility.get('status')!r}", - ) - require(isinstance(feasibility.get("gates"), list), "feasibility.gates must be a list") - for gate in feasibility["gates"]: - require(isinstance(gate, dict) and gate.get("id"), "gate requires an id") - require(gate.get("state") in GATE_STATES, f"gate state invalid: {gate.get('state')!r}") - require(isinstance(gate.get("blocks_tapeout"), bool), "gate.blocks_tapeout must be bool") + if isinstance(feasibility, dict): + require(feasibility.get("status") in FEASIBILITY_STATUSES, "feasibility.status invalid") + gates = feasibility.get("gates") + require(isinstance(gates, list), "feasibility.gates must be a list") + if isinstance(gates, list): + for gate in gates: + valid_gate = isinstance(gate, dict) + require( + valid_gate and isinstance(gate.get("id"), str) and gate["id"], + "gate requires an id", + ) + if not valid_gate: + continue + require(gate.get("state") in GATE_STATES, "gate state invalid") + require(isinstance(gate.get("stage"), str), "gate.stage must be a string") + require(isinstance(gate.get("predicate"), str), "gate.predicate must be a string") + require( + isinstance(gate.get("blocks_tapeout"), bool), "gate.blocks_tapeout must be bool" + ) + require(string_list(gate.get("metrics")), "gate.metrics must be a string list") + require( + gate.get("availability") is None or isinstance(gate.get("availability"), str), + "gate.availability invalid", + ) + timing = gate.get("timing_slack") + if timing is not None: + valid_timing = isinstance(timing, dict) + require(valid_timing, "gate.timing_slack must be an object or null") + if valid_timing: + for field in ("ws_ns", "wns_ns", "tns_ns"): + require( + finite_number(timing.get(field), nullable=True), + f"timing_slack.{field} invalid", + ) + require( + finite_number(timing.get("nvp"), low=0, nullable=True), + "timing_slack.nvp invalid", + ) + require( + timing.get("worst_corner") is None + or isinstance(timing.get("worst_corner"), str), + "timing_slack.worst_corner invalid", + ) evidence = report["evidence"] - require( - evidence.get("state") in EVIDENCE_STATES, - f"evidence.state invalid: {evidence.get('state')!r}", - ) - for field in ("index", "integrity", "coverage", "consistency"): - value = evidence.get(field) - limit = 100 if field == "index" else 1 - valid = isinstance(value, (int, float)) and 0 <= value <= limit - require(value is None or valid, f"evidence.{field} out of range") + require(isinstance(evidence, dict), "evidence must be an object") + if isinstance(evidence, dict): + require(evidence.get("state") in EVIDENCE_STATES, "evidence.state invalid") + require( + finite_number(evidence.get("index"), low=0, high=100, nullable=True), + "evidence.index out of range", + ) + for field in ("integrity", "coverage", "consistency"): + require( + finite_number(evidence.get(field), low=0, high=1, nullable=True), + f"evidence.{field} out of range", + ) qor_record = report["qor_record"] require(isinstance(qor_record, dict), "qor_record must be an object") - for key in DIMENSION_KEYS: - dimension = qor_record.get(key) - require(isinstance(dimension, dict), f"qor_record.{key} missing") - if isinstance(dimension, dict): - value = dimension.get("value") + if isinstance(qor_record, dict): + for key in DIMENSION_KEYS: + dimension = qor_record.get(key) + valid_dimension = isinstance(dimension, dict) + require(valid_dimension, f"qor_record.{key} missing") + if not valid_dimension: + continue + require(dimension.get("key") == key, f"qor_record.{key}.key mismatch") require( - value is None or (isinstance(value, (int, float)) and 0 <= value <= 100), - f"qor_record.{key}.value out of range", - ) - require( - dimension.get("state") in DIMENSION_STATES, - f"qor_record.{key}.state invalid", - ) - require( - isinstance(dimension.get("features"), list), - "dimension.features must be a list", + finite_number(dimension.get("value"), low=0, high=100, nullable=True), + f"qor_record.{key}.value invalid", ) + require(dimension.get("state") in DIMENSION_STATES, f"qor_record.{key}.state invalid") + features = dimension.get("features") + require(isinstance(features, list), f"qor_record.{key}.features must be a list") + if isinstance(features, list): + for feature in features: + valid_feature = isinstance(feature, dict) + require(valid_feature, "feature must be an object") + if not valid_feature: + continue + for field in ( + "feature_id", + "unit", + "formula", + "classification", + "semantic_class", + ): + require( + isinstance(feature.get(field), str), f"feature.{field} must be a string" + ) + require( + finite_number(feature.get("value"), nullable=True), "feature.value invalid" + ) + require( + feature.get("state") + in ("PASS", "FAIL", "WATCH", "OPPORTUNITY", "UNKNOWN", "NOT_APPLICABLE"), + "feature.state invalid", + ) + require( + string_list(feature.get("input_metric_ids")), + "feature.input_metric_ids invalid", + ) + require( + isinstance(feature.get("input_source_artifacts"), list), + "feature.input_source_artifacts invalid", + ) summary = report["scalar_summary"] - require( - summary.get("status") in SCALAR_STATUSES, - f"scalar_summary.status invalid: {summary.get('status')!r}", - ) - score = summary.get("score") - require( - score is None or (isinstance(score, (int, float)) and 0 <= score <= 100), - "scalar_summary.score out of range", - ) - require(isinstance(summary.get("weights"), dict), "scalar_summary.weights must be an object") - - require(isinstance(report["diagnoses"], list), "diagnoses must be a list") - for diagnosis in report["diagnoses"]: + require(isinstance(summary, dict), "scalar_summary must be an object") + if isinstance(summary, dict): + require(summary.get("status") in SCALAR_STATUSES, "scalar_summary.status invalid") require( - isinstance(diagnosis, dict) and diagnosis.get("diagnosis_id"), - "diagnosis requires id", + finite_number(summary.get("score"), low=0, high=100, nullable=True), + "scalar_summary.score out of range", ) - severity = diagnosis.get("severity") + require(isinstance(summary.get("profile"), str), "scalar_summary.profile must be a string") require( - isinstance(severity, (int, float)) and 0 <= severity <= 1, - f"diagnosis severity out of range: {severity!r}", + isinstance(summary.get("weights"), dict), "scalar_summary.weights must be an object" ) - require( - diagnosis.get("diagnosis_confidence") in CONFIDENCES, - "diagnosis_confidence invalid", - ) - for intervention in diagnosis.get("interventions") or []: - require(intervention.get("tier") in TIERS, "intervention tier invalid") + + diagnoses = report["diagnoses"] + require(isinstance(diagnoses, list), "diagnoses must be a list") + if isinstance(diagnoses, list): + for diagnosis in diagnoses: + valid_diagnosis = isinstance(diagnosis, dict) require( - intervention.get("confidence") in CONFIDENCES, - "intervention confidence invalid", + valid_diagnosis + and isinstance(diagnosis.get("diagnosis_id"), str) + and diagnosis["diagnosis_id"], + "diagnosis requires id", ) + if not valid_diagnosis: + continue + require(isinstance(diagnosis.get("state"), str), "diagnosis.state invalid") + require( + finite_number(diagnosis.get("severity"), low=0, high=1), + "diagnosis severity invalid", + ) + require( + diagnosis.get("diagnosis_confidence") in CONFIDENCES, "diagnosis_confidence invalid" + ) + require( + string_list(diagnosis.get("trigger_features")), "diagnosis.trigger_features invalid" + ) + require( + string_list(diagnosis.get("affected_dimensions")), + "diagnosis.affected_dimensions invalid", + ) + require( + isinstance(diagnosis.get("interpretation"), str), "diagnosis.interpretation invalid" + ) + interventions = diagnosis.get("interventions") + require(isinstance(interventions, list), "diagnosis.interventions must be a list") + if isinstance(interventions, list): + for intervention in interventions: + valid_intervention = isinstance(intervention, dict) + require(valid_intervention, "intervention must be an object") + if valid_intervention: + require( + isinstance(intervention.get("hypothesis"), str), + "intervention.hypothesis invalid", + ) + require(intervention.get("tier") in TIERS, "intervention tier invalid") + require( + intervention.get("confidence") in CONFIDENCES, + "intervention confidence invalid", + ) + + inflation = report["inflation"] + require(isinstance(inflation, dict), "inflation must be an object") + if isinstance(inflation, dict): + for field in ("i_place", "i_route", "i_total", "congestion_severity"): + require( + finite_number(inflation.get(field), low=0, nullable=True), + f"inflation.{field} invalid", + ) + require( + inflation.get("compatibility_status") + in ("EXACT_COMPATIBLE", "MAPPED_COMPATIBLE", "INCOMPATIBLE"), + "inflation.compatibility_status invalid", + ) + require(isinstance(report["flow_steps"], dict), "flow_steps must be an object") + require(string_list(report.get("config_warnings", [])), "config_warnings must be a string list") return errors diff --git a/chipcompiler/engine/flow.py b/chipcompiler/engine/flow.py index 65d8ac83a..6e13ac7bc 100644 --- a/chipcompiler/engine/flow.py +++ b/chipcompiler/engine/flow.py @@ -608,6 +608,14 @@ def run_step( self.workspace.logger.info("[SKIP] %s already succeeded", step_tag) self.clear_db_engine_after_step(workspace_step, StateEnum.Success) _notify_flow_observer(observer, "on_step_skipped", workspace_step) + try: + from chipcompiler.analysis.qor import refresh_workspace_qor_report + + refresh_workspace_qor_report(self.workspace) + except Exception: + self.workspace.logger.exception( + "[QOR] %s failed to refresh the workspace QoR report after skip", step_tag + ) return StateEnum.Success self._normalize_legacy_terminal_state(workspace_step, step_tag) diff --git a/chipcompiler/tools/ecc/metrics.py b/chipcompiler/tools/ecc/metrics.py index efa854cf0..33077a7b8 100644 --- a/chipcompiler/tools/ecc/metrics.py +++ b/chipcompiler/tools/ecc/metrics.py @@ -3836,7 +3836,9 @@ def build_metrics_sta(workspace: Workspace, step: EccStep) -> StepMetrics: if hold_tns is None or summary.hold_tns < hold_tns: hold_tns = summary.hold_tns hold_tns_corner = summary.corner - if frequency is None or summary.frequency_mhz < frequency: + if summary.frequency_mhz is not None and ( + frequency is None or summary.frequency_mhz < frequency + ): frequency = summary.frequency_mhz frequency_corner = summary.corner setup_violation_count += summary.setup_nvp diff --git a/chipcompiler/tools/ecc/sta_qor.py b/chipcompiler/tools/ecc/sta_qor.py index 593af21aa..912eaf65a 100644 --- a/chipcompiler/tools/ecc/sta_qor.py +++ b/chipcompiler/tools/ecc/sta_qor.py @@ -32,10 +32,10 @@ class StaQorSummary: setup_wns: float setup_tns: float setup_nvp: int - frequency_mhz: float - hold_wns: float - hold_tns: float - hold_nvp: int + frequency_mhz: float | None + hold_wns: float | None + hold_tns: float | None + hold_nvp: int | None @dataclass(frozen=True) @@ -165,7 +165,9 @@ def _nonnegative_int(value) -> int | None: return value -def read_sta_qor_summary(corner: str, path: Path) -> StaQorSummary | None: +def read_sta_qor_summary( + corner: str, path: Path, *, require_hold: bool = True +) -> StaQorSummary | None: if not path.is_file() or path.stat().st_size <= 0: return None @@ -178,25 +180,22 @@ def read_sta_qor_summary(corner: str, path: Path) -> StaQorSummary | None: return None setup = summary.get("setup") hold = summary.get("hold") - if not isinstance(setup, dict) or not isinstance(hold, dict): + if not isinstance(setup, dict): return None setup_wns = _finite_number(setup.get("wns")) setup_tns = _finite_number(setup.get("tns")) setup_nvp = _nonnegative_int(setup.get("nvp")) frequency_mhz = _finite_number(setup.get("frequency_mhz")) - hold_wns = _finite_number(hold.get("wns")) - hold_tns = _finite_number(hold.get("tns")) - hold_nvp = _nonnegative_int(hold.get("nvp")) + hold_wns = _finite_number(hold.get("wns")) if isinstance(hold, dict) else None + hold_tns = _finite_number(hold.get("tns")) if isinstance(hold, dict) else None + hold_nvp = _nonnegative_int(hold.get("nvp")) if isinstance(hold, dict) else None if ( setup_wns is None or setup_tns is None or setup_nvp is None - or frequency_mhz is None - or frequency_mhz <= 0 - or hold_wns is None - or hold_tns is None - or hold_nvp is None + or (require_hold and (frequency_mhz is None or frequency_mhz <= 0)) + or (require_hold and (hold_wns is None or hold_tns is None or hold_nvp is None)) ): return None diff --git a/test/analysis/qor/helpers.py b/test/analysis/qor/helpers.py index 17a36592a..47d6e3feb 100644 --- a/test/analysis/qor/helpers.py +++ b/test/analysis/qor/helpers.py @@ -14,6 +14,7 @@ def make_metric(metric_id, value, step="sta", role="final", unit="", corner=None project_role=role, corner=corner, source={"kind": "analysis", "path": f"{step}_ecc/analysis/qor_metrics.json"}, + scope="project", ) diff --git a/test/analysis/qor/test_dimensions.py b/test/analysis/qor/test_dimensions.py index a99aa105c..4a2646c8b 100644 --- a/test/analysis/qor/test_dimensions.py +++ b/test/analysis/qor/test_dimensions.py @@ -3,6 +3,7 @@ from chipcompiler.analysis.qor.calibration import TAU_AREA_FAIL, psi_cost from chipcompiler.analysis.qor.dimensions import evaluate_dimensions from chipcompiler.analysis.qor.features import compute_features +from chipcompiler.analysis.qor.loader import CornerSlack from test.analysis.qor.helpers import gcd_corners, gcd_metrics, make_inputs, make_metric @@ -126,6 +127,19 @@ def test_missing_pvt_renormalizes_to_cts_weight(self): # imbalance = (5-1)/5 = 0.8; the single contributor renormalizes to w=1. assert dims["robustness"].value == pytest.approx(20.0) + def test_single_corner_uses_zero_pvt_fallback(self): + metrics = gcd_metrics() + metrics["clock_path_max_buffer"].value = 100 + metrics["clock_path_min_buffer"].value = 20 + _, dims = _dimensions( + make_inputs( + metrics, + corners=[CornerSlack("single", 0.0, None, None, None)], + tclk_ns=20.0, + ) + ) + assert dims["robustness"].value == pytest.approx(60.0) + def test_missing_both_contributors_is_unknown(self): metrics = gcd_metrics() del metrics["clock_path_max_buffer"] diff --git a/test/analysis/qor/test_evidence.py b/test/analysis/qor/test_evidence.py index d7e65e148..2ff1662a2 100644 --- a/test/analysis/qor/test_evidence.py +++ b/test/analysis/qor/test_evidence.py @@ -2,6 +2,7 @@ from chipcompiler.analysis.qor.evidence import evaluate_evidence from chipcompiler.analysis.qor.features import compute_features +from chipcompiler.analysis.qor.loader import CornerSlack from test.analysis.qor.helpers import gcd_metrics, make_inputs @@ -56,6 +57,23 @@ def test_consistency_c1_not_applicable_when_incompatible(self): # Route side is INCOMPATIBLE (CTS ran), so C1 drops out. assert evidence.consistency == pytest.approx(1.0) + def test_c2_is_not_applied_across_different_scopes(self): + metrics = gcd_metrics() + metrics["sta_setup_wns"].scope = "project" + metrics["sta_setup_violation_count"].scope = "corner" + evidence = _evidence(make_inputs(metrics)) + assert evidence.consistency == pytest.approx(1.0) + + def test_setup_only_corners_cap_evidence_at_moderate(self): + evidence = _evidence( + make_inputs( + gcd_metrics(), + corners=[CornerSlack("single", 1.0, None, None, None)], + sta_setup_only=True, + ) + ) + assert evidence.state == "MODERATE" + def test_zero_expected_corners_is_not_a_division_error(self): metrics = gcd_metrics() for metric_id in ("sta_expected_corner_count", "sta_missing_corner_count"): diff --git a/test/analysis/qor/test_features.py b/test/analysis/qor/test_features.py new file mode 100644 index 000000000..63da81b4f --- /dev/null +++ b/test/analysis/qor/test_features.py @@ -0,0 +1,23 @@ +from chipcompiler.analysis.qor.features import compute_features +from test.analysis.qor.helpers import make_inputs, make_metric + + +def test_leakage_fraction_handles_zero_dynamic_power(): + inputs = make_inputs( + { + "synthesis_power_dynamic_uw": make_metric("synthesis_power_dynamic_uw", 0.0), + "synthesis_power_leakage_uw": make_metric("synthesis_power_leakage_uw", 5.0), + } + ) + feature = compute_features(inputs).features["F_SYN_LEAK_FRAC"] + assert feature.value == 1.0 + + +def test_frequency_margin_is_emitted_as_diagnostic_feature(): + inputs = make_inputs( + {"sta_setup_wns": make_metric("sta_setup_wns", 0.1)}, + tclk_ns=1.0, + ) + feature = compute_features(inputs).features["F_STA_FREQ_MARGIN"] + assert feature.value is not None + assert feature.state == "OPPORTUNITY" diff --git a/test/analysis/qor/test_loader.py b/test/analysis/qor/test_loader.py index c9db8c9f7..1038a44bf 100644 --- a/test/analysis/qor/test_loader.py +++ b/test/analysis/qor/test_loader.py @@ -207,6 +207,113 @@ def test_per_corner_summaries_are_parsed(self, tmp_path): inputs = load_workspace_qor_inputs(workspace) assert [corner.setup_ws for corner in inputs.corners] == [16.622, 18.98] + def test_failed_sta_does_not_load_stale_corner_files(self, tmp_path): + steps = dict(_FULL_FLOW) + steps["sta"] = "Imcomplete" + workspace = _make_workspace(tmp_path, steps) + _write( + os.path.join( + workspace.directory, + "sta_ecc", + "feature", + "MAX_125_t125", + "Cworst", + "qor_summary.json", + ), + { + "path_groups": [], + "summary": { + "setup": {"wns": 1.0, "tns": 0.0, "nvp": 0, "frequency_mhz": 50.0}, + "hold": {"wns": 1.0, "tns": 0.0, "nvp": 0}, + }, + }, + ) + inputs = load_workspace_qor_inputs(workspace) + assert inputs.corners == [] + + def test_synthesis_power_summary_is_loaded(self, tmp_path): + workspace = _make_workspace(tmp_path, _FULL_FLOW) + _write( + os.path.join( + workspace.directory, + "Synthesis_yosys", + "feature", + "post_synthesis", + "power_summary.json", + ), + { + "schema_version": 1, + "dynamic_uw": 3.0, + "leakage_uw": 4.0, + "internal_uw": 1.0, + "switching_uw": 2.0, + }, + ) + inputs = load_workspace_qor_inputs(workspace) + assert inputs.power_total_uw == 7.0 + assert inputs.power_source_path.endswith( + "Synthesis_yosys/feature/post_synthesis/power_summary.json" + ) + + def test_signoff_power_uses_worst_configured_corner(self, tmp_path): + workspace = _make_workspace(tmp_path, _FULL_FLOW) + sta_config_path = os.path.join(workspace.directory, "home", "sta_ecc.json") + _write( + sta_config_path, + { + "liberty": [ + {"corner": "MAX", "temperature": 125}, + {"corner": "MIN", "temperature": -40}, + ], + "signoff": [{"MAX": ["Cworst"]}, {"MIN": ["Cbest"]}], + }, + ) + workspace.config = {"sta": sta_config_path} + for corner_dir, rcx_dir, total in ( + ("MAX_125", "Cworst", 5.0), + ("MIN_m40", "Cbest", 9.0), + ): + _write( + os.path.join( + workspace.directory, + "sta_ecc", + "feature", + corner_dir, + rcx_dir, + "power_summary.json", + ), + { + "schema_version": 1, + "dynamic_uw": total - 1.0, + "leakage_uw": 1.0, + "internal_uw": 1.0, + "switching_uw": total - 1.0, + }, + ) + inputs = load_workspace_qor_inputs(workspace) + assert inputs.power_total_uw == 9.0 + assert inputs.power_source_path.endswith("MIN_m40/Cbest/power_summary.json") + + def test_setup_only_corner_is_loaded_with_fallback_marker(self, tmp_path): + workspace = _make_workspace(tmp_path, _FULL_FLOW) + _write( + os.path.join( + workspace.directory, + "sta_ecc", + "feature", + "MAX_125_t125", + "Cworst", + "qor_summary.json", + ), + { + "path_groups": [], + "summary": {"setup": {"wns": 1.0, "tns": 0.0, "nvp": 0}}, + }, + ) + inputs = load_workspace_qor_inputs(workspace) + assert len(inputs.corners) == 1 + assert inputs.sta_setup_only is True + class TestDesignResolution: def test_design_falls_back_to_parameters(self, tmp_path): diff --git a/test/analysis/qor/test_schema.py b/test/analysis/qor/test_schema.py index cd91ec485..829ca8c55 100644 --- a/test/analysis/qor/test_schema.py +++ b/test/analysis/qor/test_schema.py @@ -41,6 +41,22 @@ def test_out_of_range_score_is_detected(self): errors = validate_report(payload) assert any("score out of range" in error for error in errors) + def test_malformed_nested_blocks_return_errors_without_raising(self): + from unittest import mock + + inputs = make_inputs(gcd_metrics(), corners=gcd_corners(), tclk_ns=20.0) + with mock.patch( + "chipcompiler.analysis.qor.load_workspace_qor_inputs", + lambda workspace: inputs, + ): + payload = build_qor_analysis(workspace=None).to_dict() + payload["feasibility"] = None + assert validate_report(payload) + + payload["feasibility"] = {"status": "PASS", "gates": []} + payload["diagnoses"] = [{"diagnosis_id": "broken", "interventions": [None]}] + assert validate_report(payload) + def test_invalid_gate_state_is_detected(self): from unittest import mock From a152be3400eca605b6e428cefe39811359e78703 Mon Sep 17 00:00:00 2001 From: Yell-walkalone <12112088@qq.com> Date: Thu, 10 Sep 2026 11:28:01 +0800 Subject: [PATCH 3/7] fix(qor): expose power observations in reports --- chipcompiler/analysis/qor/__init__.py | 8 ++++++ chipcompiler/analysis/qor/loader.py | 38 +++++++++++++++++++++---- chipcompiler/analysis/qor/models.py | 12 ++++++++ chipcompiler/analysis/qor/renderer.py | 18 ++++++++---- chipcompiler/analysis/qor/schema.py | 22 ++++++++++++++ test/analysis/qor/test_loader.py | 4 +++ test/analysis/qor/test_reference_gcd.py | 16 +++++++++++ test/analysis/qor/test_schema.py | 20 +++++++++++++ 8 files changed, 127 insertions(+), 11 deletions(-) diff --git a/chipcompiler/analysis/qor/__init__.py b/chipcompiler/analysis/qor/__init__.py index 10a80d4aa..1ff5a4275 100644 --- a/chipcompiler/analysis/qor/__init__.py +++ b/chipcompiler/analysis/qor/__init__.py @@ -20,6 +20,7 @@ SCHEMA_VERSION, SCORING_ENGINE, InflationView, + PowerObservation, QorAnalysis, ) from chipcompiler.analysis.qor.scoring import evaluate_scalar_summary @@ -60,6 +61,13 @@ def build_qor_analysis(workspace) -> QorAnalysis: bundle.compatibility.status if bundle.compatibility else "INCOMPATIBLE" ), ), + power=PowerObservation( + total_uw=inputs.power_total_uw, + budget_uw=inputs.power_budget_uw, + source_path=inputs.power_source_path, + source_kind=inputs.power_source_kind, + corner=inputs.power_corner, + ), flow_steps=dict(inputs.flow_states), config_warnings=list(inputs.config_warnings), ) diff --git a/chipcompiler/analysis/qor/loader.py b/chipcompiler/analysis/qor/loader.py index d7232f269..e774f40d8 100644 --- a/chipcompiler/analysis/qor/loader.py +++ b/chipcompiler/analysis/qor/loader.py @@ -64,6 +64,8 @@ class QorInputs: rcx_expected_spef: float | None = None power_total_uw: float | None = None power_source_path: str | None = None + power_source_kind: str | None = None + power_corner: str | None = None sta_setup_only: bool = False tclk_ns: float | None = None profile: str = "balanced" @@ -199,13 +201,28 @@ def _power_summary(workspace, workspace_root: Path, flow_states: dict): if flow_states.get(StepEnum.STA.value) == StateEnum.Success.value: feature_root = workspace_root / _STA_FEATURE_DIR totals = [] - for _, directory in configured_sta_artifact_directories(workspace, feature_root): + configured = configured_sta_artifact_directories(workspace, feature_root) + if configured: + candidates = configured + else: + # Keep report generation useful for fixtures and workspaces whose + # STA config is unavailable: only persisted feature artifacts are + # considered, and the selected path remains auditable. + candidates = [ + ( + path.parent.relative_to(feature_root).as_posix(), + path.parent, + ) + for path in sorted(feature_root.glob("*/*/" + STA_POWER_SUMMARY_FILENAME)) + ] + for corner, directory in candidates: path = directory / STA_POWER_SUMMARY_FILENAME summary = read_sta_power_summary_json(path) if summary is not None: - totals.append((summary.dynamic_uw + summary.leakage_uw, path)) + totals.append((summary.dynamic_uw + summary.leakage_uw, path, corner)) if totals: - return max(totals, key=lambda item: item[0]) + total, path, corner = max(totals, key=lambda item: item[0]) + return total, path, "signoff", corner if flow_states.get(StepEnum.SYNTHESIS.value) == StateEnum.Success.value: path = ( @@ -217,8 +234,13 @@ def _power_summary(workspace, workspace_root: Path, flow_states: dict): ) summary = read_sta_power_summary_json(path) if summary is not None: - return summary.dynamic_uw + summary.leakage_uw, path - return None, None + return ( + summary.dynamic_uw + summary.leakage_uw, + path, + "synthesis", + POST_SYNTHESIS_STA_CORNER, + ) + return None, None, None, None def _resolve_parameters(parameters: dict, warnings: list) -> tuple: @@ -288,7 +310,9 @@ def _metric_value(metric_id: str) -> float | None: record = metrics.get(metric_id) return record.value if record is not None else None - power_total_uw, power_source = _power_summary(workspace, workspace_root, flow_states) + power_total_uw, power_source, power_source_kind, power_corner = _power_summary( + workspace, workspace_root, flow_states + ) corners, sta_setup_only = _corner_slack(workspace, workspace_root, flow_states) return QorInputs( @@ -305,6 +329,8 @@ def _metric_value(metric_id: str) -> float | None: rcx_expected_spef=_metric_value("rcx_expected_corner_count"), power_total_uw=power_total_uw, power_source_path=str(power_source) if power_source is not None else None, + power_source_kind=power_source_kind, + power_corner=power_corner, sta_setup_only=sta_setup_only, tclk_ns=tclk_ns, profile=profile, diff --git a/chipcompiler/analysis/qor/models.py b/chipcompiler/analysis/qor/models.py index 15d90bcff..5419eb1c3 100644 --- a/chipcompiler/analysis/qor/models.py +++ b/chipcompiler/analysis/qor/models.py @@ -149,6 +149,17 @@ class InflationView: compatibility_status: str +@dataclasses.dataclass(frozen=True) +class PowerObservation: + """Raw power observation used by the QoR report and GUI breakdown.""" + + total_uw: float | None + budget_uw: float | None + source_path: str | None + source_kind: str | None # signoff | synthesis + corner: str | None + + @dataclasses.dataclass(frozen=True) class QorAnalysis: schema_version: int @@ -164,6 +175,7 @@ class QorAnalysis: scalar_summary: ScalarSummary diagnoses: list inflation: InflationView + power: PowerObservation flow_steps: dict # step value -> persisted state snapshot config_warnings: list diff --git a/chipcompiler/analysis/qor/renderer.py b/chipcompiler/analysis/qor/renderer.py index 2f3dafbfa..db14f5455 100644 --- a/chipcompiler/analysis/qor/renderer.py +++ b/chipcompiler/analysis/qor/renderer.py @@ -162,12 +162,20 @@ def _area_note(analysis, inputs): def _power_note(analysis, inputs): - if analysis.qor_record["power"].value is not None and inputs is not None: + observation = getattr(analysis, "power", None) + total = getattr(observation, "total_uw", None) + budget = getattr(observation, "budget_uw", None) + if total is None and inputs is not None: total = inputs.power_total_uw - if total is not None: - return f"(Ptotal: {_fmt(total / 1e6, 3)}W of {_fmt(inputs.power_budget_uw / 1e6, 3)}W)" - return "" - if inputs is not None and inputs.power_budget_uw is None: + if budget is None and inputs is not None: + budget = inputs.power_budget_uw + if total is not None: + if budget is not None: + return f"(Ptotal: {_fmt(total / 1e6, 3)}W of {_fmt(budget / 1e6, 3)}W)" + return f"(Ptotal: {_fmt(total / 1e6, 3)}W; no budget declared)" + if budget is None: + if observation is None and inputs is None: + return "(UNKNOWN)" return "(No budget declared)" return "(UNKNOWN)" diff --git a/chipcompiler/analysis/qor/schema.py b/chipcompiler/analysis/qor/schema.py index 5b91f8add..3ac59990c 100644 --- a/chipcompiler/analysis/qor/schema.py +++ b/chipcompiler/analysis/qor/schema.py @@ -35,6 +35,7 @@ "feasibility", "evidence", "qor_record", + "power", "scalar_summary", "diagnoses", "inflation", @@ -204,6 +205,27 @@ def string_list(value): "feature.input_source_artifacts invalid", ) + power = report["power"] + require(isinstance(power, dict), "power must be an object") + if isinstance(power, dict): + for field in ("total_uw", "budget_uw"): + require( + finite_number(power.get(field), low=0, nullable=True), + f"power.{field} invalid", + ) + require( + power.get("source_path") is None or isinstance(power.get("source_path"), str), + "power.source_path invalid", + ) + require( + power.get("source_kind") in (None, "signoff", "synthesis"), + "power.source_kind invalid", + ) + require( + power.get("corner") is None or isinstance(power.get("corner"), str), + "power.corner invalid", + ) + summary = report["scalar_summary"] require(isinstance(summary, dict), "scalar_summary must be an object") if isinstance(summary, dict): diff --git a/test/analysis/qor/test_loader.py b/test/analysis/qor/test_loader.py index 1038a44bf..4c6be77ff 100644 --- a/test/analysis/qor/test_loader.py +++ b/test/analysis/qor/test_loader.py @@ -254,6 +254,8 @@ def test_synthesis_power_summary_is_loaded(self, tmp_path): assert inputs.power_source_path.endswith( "Synthesis_yosys/feature/post_synthesis/power_summary.json" ) + assert inputs.power_source_kind == "synthesis" + assert inputs.power_corner == "post_synthesis" def test_signoff_power_uses_worst_configured_corner(self, tmp_path): workspace = _make_workspace(tmp_path, _FULL_FLOW) @@ -293,6 +295,8 @@ def test_signoff_power_uses_worst_configured_corner(self, tmp_path): inputs = load_workspace_qor_inputs(workspace) assert inputs.power_total_uw == 9.0 assert inputs.power_source_path.endswith("MIN_m40/Cbest/power_summary.json") + assert inputs.power_source_kind == "signoff" + assert inputs.power_corner == "MIN_m40/Cbest" def test_setup_only_corner_is_loaded_with_fallback_marker(self, tmp_path): workspace = _make_workspace(tmp_path, _FULL_FLOW) diff --git a/test/analysis/qor/test_reference_gcd.py b/test/analysis/qor/test_reference_gcd.py index 43dd51fbf..6426e7b2a 100644 --- a/test/analysis/qor/test_reference_gcd.py +++ b/test/analysis/qor/test_reference_gcd.py @@ -9,6 +9,7 @@ import json import os +from dataclasses import replace from types import SimpleNamespace import pytest @@ -19,6 +20,7 @@ render_qor_analysis, ) from chipcompiler.analysis.qor.loader import load_workspace_qor_inputs +from chipcompiler.analysis.qor.models import PowerObservation from chipcompiler.analysis.qor.schema import validate_report SUCCESS = "Success" @@ -236,3 +238,17 @@ def test_renderer_matches_spec_layout(self, tmp_path): assert "WS: +16.622ns" in text assert "[PRIORITIZED INTERVENTION HYPOTHESES]" in text assert "diag.timing.over_provisioned" in text + + def test_renderer_includes_observed_power_without_budget(self, analysis): + report = replace( + analysis, + power=PowerObservation( + total_uw=12_400.0, + budget_uw=None, + source_path="/ws/sta_ecc/feature/MAX_125/Cworst/power_summary.json", + source_kind="signoff", + corner="MAX_125/Cworst", + ), + ) + text = render_qor_analysis(report) + assert "Ptotal: 0.012W; no budget declared" in text diff --git a/test/analysis/qor/test_schema.py b/test/analysis/qor/test_schema.py index 829ca8c55..050f79555 100644 --- a/test/analysis/qor/test_schema.py +++ b/test/analysis/qor/test_schema.py @@ -14,6 +14,13 @@ def test_assembled_report_validates(self): ): analysis = build_qor_analysis(workspace=None) assert validate_report(analysis.to_dict()) == [] + assert analysis.to_dict()["power"] == { + "total_uw": None, + "budget_uw": None, + "source_path": None, + "source_kind": None, + "corner": None, + } def test_missing_required_key_is_detected(self): from unittest import mock @@ -69,3 +76,16 @@ def test_invalid_gate_state_is_detected(self): payload["feasibility"]["gates"][0]["state"] = "maybe" errors = validate_report(payload) assert any("gate state invalid" in error for error in errors) + + def test_invalid_power_observation_is_detected(self): + from unittest import mock + + inputs = make_inputs(gcd_metrics(), corners=gcd_corners(), tclk_ns=20.0) + with mock.patch( + "chipcompiler.analysis.qor.load_workspace_qor_inputs", + lambda workspace: inputs, + ): + payload = build_qor_analysis(workspace=None).to_dict() + payload["power"]["source_kind"] = "estimated" + errors = validate_report(payload) + assert any("power.source_kind invalid" in error for error in errors) From cfa9142e75fd2b2e7ab6764dc87ac44e72ec5a8f Mon Sep 17 00:00:00 2001 From: Yell-walkalone <12112088@qq.com> Date: Thu, 10 Sep 2026 15:15:25 +0800 Subject: [PATCH 4/7] docs(qor): add ECC QoR v3 reference manuals Add paired cn/en reference manuals for the qor-v3 scoring engine: score computation, report reading (schema_version 3), parameter configuration, and diagnostics usage. --- chipcompiler/docs/ecc-qor-ref.cn.md | 483 ++++++++++++++++++++++++++++ chipcompiler/docs/ecc-qor-ref.en.md | 483 ++++++++++++++++++++++++++++ 2 files changed, 966 insertions(+) create mode 100644 chipcompiler/docs/ecc-qor-ref.cn.md create mode 100644 chipcompiler/docs/ecc-qor-ref.en.md diff --git a/chipcompiler/docs/ecc-qor-ref.cn.md b/chipcompiler/docs/ecc-qor-ref.cn.md new file mode 100644 index 000000000..3bebf47a0 --- /dev/null +++ b/chipcompiler/docs/ecc-qor-ref.cn.md @@ -0,0 +1,483 @@ +# ECC QoR 参考手册(质量评分 · 可行性门禁 · 证据与诊断) + +本文整理 ECC 当前 QoR 方案(**ECC-QoR draft 3**,评分引擎标识 `qor-v3`,报告 `schema_version: 3`),面向使用 ECC CLI 与 ECOS Studio 的工程师:分数怎么算、报告怎么读、参数怎么配、诊断怎么用。全部公式、阈值与默认值均核对自实现源码 [chipcompiler/analysis/qor/](../analysis/qor/)(分支 `yell/qor_v2`,2026-09)。 + +- 命令用法与安装 → [ECC CLI 用户指南](ecc-user-guide.cn.md);从零上手 → [入门教程](ecc-tutorial.cn.md) +- 每步工具配置参数 → [ECC Flow 工具配置参考](ecc-config-ref.cn.md) +- 本文不要求 flow 跑完:跑到哪一步,QoR 就评估到哪一步(未执行的步骤按"未验证"处理,见 §2.2)。 + +## 0. 一图看懂 + +```mermaid +graph LR + A["各步骤产物
qor_metrics.json / qor_summary.json
power_summary.json"] --> B["ECC QoR 引擎
qor-v3(唯一计算方)"] + B --> C["home/qor_report.json
每步成功后自动刷新"] + C --> D["ECOS Studio
(渲染方:五维分解/门禁/诊断)"] + B --> E["ecc report qor
文本报告 → signoff/*.txt"] +``` + +三条设计原则,理解了它们就读懂了全部输出: + +1. **质量 ≠ 可行性 ≠ 证据**(§2)。五个质量分数再高,只要一条物理签核门禁失败,总分恒为 0;证据不全则不给分(NOT_RATED),而不是编造一个分数。 +2. **相对基准,跨设计可比**(§3)。布线质量按"相对几何下界(HPWL)的膨胀率"评分,而不是绝对线长阈值——大设计的 50000 µm 布线与小设计的 3000 µm 可以直接比较。 +3. **缺数据 = 显式 UNKNOWN**(§2.3)。"测得 0"(如 DRC 违规数为 0,是好消息)与"没测到"(步骤没跑/报告损坏,是未知)严格区分,后者绝不折算成 0 分。 + +## 1. 快速上手 + +### 1.1 在哪里看 QoR + +| 入口 | 产物 | 刷新时机 | +|---|---|---| +| flow 引擎自动写 | `/home/qor_report.json`(机器可读,JSON Schema v3,见 §10) | 每个步骤成功后(含跳过已成功步骤时)自动刷新 | +| `ecc report qor` | `/signoff/_qor_report.txt`(人类可读文本报告) | 每次执行都按当前产物现算快照 | +| ECOS Studio | 项目看板 QoR 卡、五维分解、诊断列表 | 读取 `home/qor_report.json`,无报告或陈旧时显示 NOT_RATED(§10.2) | + +```bash +ecc report qor --project gcd # 写 signoff/gcd_qor_report.txt +ecc report qor --plain # key=value 摘要(脚本可解析) +ecc report qor -o /tmp/qor.txt # 自定义输出路径 +``` + +`--plain` 摘要字段:`overall_score`(总分或 null)、`qor_status`(GREEN/YELLOW/ORANGE/RED/FAIL/NOT_RATED)、`gate_status`(可行性状态)、`dimensions[]`(各维度 score/state)。 + +### 1.2 文本报告样例(gcd 参考数值) + +``` +============================================================================== + ECC QoR ANALYSIS REPORT - Design: gcd + Workspace: ~/ecc-demo/gcd/ws_0001 +============================================================================== + FEASIBILITY STATUS : PASS [All 7 Physical Signoff Gates Clean] + EVIDENCE STATE : HIGH [Integrity: 100.0%, Coverage: 100.0%, Consistency: 100.0%] + QoR COMPOSITE : 99.0 / 100 (Status: GREEN, Profile: balanced) +------------------------------------------------------------------------------ + [PHYSICAL QoR RECORD BREAKDOWN] + Timing Quality (Q_T) : 100.0 / 100 [OPPORTUNITY] (WS: +16.622ns, WNS: 0ns) + Interconnect Quality (Q_I): 100.0 / 100 [PASS] (I_place: 1.213 (INCOMPATIBLE route side), S_cong: 0.00) + Area Efficiency (Q_A) : 100.0 / 100 [PASS] (Core Util: 52.0%) + Power Quality (Q_P) : — / 100 [UNKNOWN] (No budget declared) + Robustness (Q_R) : 94.1 / 100 [PASS] (CTS Imbal: 0.0, PVT Spread: 2.36ns) +------------------------------------------------------------------------------ + [PRIMARY DIAGNOSES] + (No active feasibility blockers detected) + + [WATCH & OPPORTUNITY DIAGNOSES] + [OPPORTUNITY] diag.timing.over_provisioned (Severity: 0.79, Confidence: HIGH) + Timing margin (+16.622ns) exceeds the over-provisioning threshold (4ns); the design appears over-constrained. +------------------------------------------------------------------------------ + [PRIORITIZED INTERVENTION HYPOTHESES] + 1. [Tier 3 (Opportunity)] Intervention hypothesis: downsize drive strengths to recover power and area correlated with the excess margin. +============================================================================== +``` + +读法:**FEASIBILITY** 是能不能造(§5);**EVIDENCE** 是数据可不可信(§6);**QoR COMPOSITE** 是综合分与状态色(§4);五维 BREAKDOWN 是质量分解(§3);DIAGNOSES 是确定性问题定位,INTERVENTIONS 是排好序的干预假设(§7)。 + +## 2. 三层语义:质量、可行性、证据 + +### 2.1 物理质量 Qphys(五维坐标) + +``` +Qphys = (Q_T, Q_I, Q_A, Q_P, Q_R),每维 ∈ [0, 100] 或 null(显式 UNKNOWN) +``` + +| 维度 | 名称 | 评什么 | 输入 | +|---|---|---|---| +| Q_T | 时序质量 | 有符号最差裕量相对 guardband 的位置 | `sta_setup_wns`(跨 corner 最小值,有符号)、`frequency_max` | +| Q_I | 互连质量 | 布线线长相对 HPWL 几何下界的膨胀率 × 拥塞惩罚 | `place_hpwl`、`place_grwl`、`route_wirelength`、拥塞代理 | +| Q_A | 面积质量 | 已布局 core 利用率落在目标区间的程度 | `core_utilization` | +| Q_P | 功耗质量 | 签核总功耗相对申报预算的剩余比例 | `qor_power_budget_w`、STA 功耗 | +| Q_P 未申报预算时恒为 null | | | | + +某维无法评估(步骤没跑到、数据缺失、无预算)时该维为 null 并标 `UNKNOWN`,**不折算为 0**,也不偷偷参与总分(见 §4.2 权重重归一)。 + +### 2.2 物理可行性 Feasibility(七条签核门禁) + +可行性回答"这个版图能不能签核出货",由 7 条零容忍门禁归约(详见 §5): + +``` +PHYSICAL_FAIL(任一门禁 failed)≻ UNKNOWN(证据损坏)≻ NOT_VERIFIED(步骤未执行)≻ PASS +``` + +- **PHYSICAL_FAIL ⇒ 总分恒为 0**:不可制造的设计不能靠面积/功耗高分掩盖。 +- **未验证 ≠ 失败**:某签核步骤没跑成功 → `NOT_VERIFIED`;步骤成功但证据缺失/损坏(如 hold 报告缺失——ECC 的 hold STA 输出是可选的)→ `UNKNOWN`。两者都只是不给分(NOT_RATED),都不是失败。 + +### 2.3 缺失数据三态 + +| 状态 | 含义 | 例 | +|---|---|---| +| 测得 0 | 物理量被成功测量且为 0,高置信证据 | `drc_count = 0`、`egr_total = 0` | +| UNKNOWN | 步骤执行了但报告缺失/损坏/不可解析 | qor_metrics.json 缺失 → 相关维度 null | +| NOT_APPLICABLE | 前置步骤被有意省略或结构前提不满足 | 未申报功耗预算的 Q_P;网络群体不兼容时的跨阶段比值 | + +## 3. 五维怎么算 + +以下公式中的默认阈值都是**校准的工程经验值**(CALIBRATED_HEURISTIC / USER_PROJECT_CONSTRAINT),不是物理定律,集中定义在 [calibration.py](../analysis/qor/calibration.py),当前未开放为用户参数。 + +### 3.1 Q_T 时序质量 + +前提:理解 **WS 与 WNS 的区别**(ECC 修正了行业惯用的语义混淆): + +- **WS(Signed Worst Slack,有符号最差裕量)**:关键路径的代数裕量,可正可负(如 +16.622 ns 或 −0.25 ns)。ECC 的 `sta_setup_wns` 指标虽然名字带 "wns",携带的实际是**有符号 WS**(跨 corner 取最小值,不钳位)。 +- **WNS = min(0, WS)**:仅用于门禁与违规诊断,**绝不作为连续质量输入**——钳位后 +5 ps 与 +2 ns 无法区分。 + +$$ +Q_T=\begin{cases}50\cdot\max\!\big(0,\;1-|WS|/\tau_{fail}\big) & WS<0\\[4pt]50+50\cdot\min\!\big(WS/\tau_{gb},\;1\big) & WS\ge 0\end{cases} +$$ + +- 参数:τ_gb = 0.05·T_clk(guardband)、τ_fail = 0.20·T_clk;T_clk = 1000 / `frequency_max`(MHz→ns)。`frequency_max` 缺失或非法 → Q_T = null。 +- 性质:WS = 0 处连续(两侧极限都是 50);正裕量连续分化(+5 ps≈55 分,guardband 满分 100);负裕量线性降到 0。 +- 维度状态(TimingState):`WS<0 → FAIL`;`0≤WS<τ_gb → WATCH`;`τ_gb≤WS≤τ_over → PASS`;`WS>τ_over → OPPORTUNITY`(过约束,τ_over = 0.20·T_clk,提示可以缩驱动换面积/功耗)。 +- 频率指标 `sta_frequency_mhz` 与 Q_T 解耦,仅作诊断特征展示。 + +### 3.2 Q_I 互连质量 + +**膨胀率分解**(相对 HPWL 几何下界;HPWL ≤ RSMT 是可证明的下界): + +$$ +I_{total}=\frac{RWL}{HPWL}=\underbrace{\frac{GRWL}{HPWL}}_{I_{place}}\times\underbrace{\frac{RWL}{GRWL}}_{I_{route}} +$$ + +- `I_place`(全局布线实现开销):网格离散化、层约束、绕行; +- `I_route`(详细布线膨胀):引脚接入、换层过孔、DRC 避让; +- 分母为 0/缺失时比值严格为 UNKNOWN,**禁止 ε 填充**(会破坏恒等式)。 + +**跨阶段兼容性(当前实现的降级路径)**:`I_route`/`I_total` 的分子分母来自不同阶段(place → route),CTS 会在两阶段之间插入时钟树网络。当前工具链没有输出网络级映射,因此: + +- CTS 插入了缓冲(或计数未知)→ place→route 判 `INCOMPATIBLE`,`I_route`/`I_total` 严格 UNKNOWN; +- Q_I 退化为按 `I_place`(place 阶段内部,同一网表,`EXACT_COMPATIBLE`)评分,报告中如实标注 `INCOMPATIBLE route side`; +- 待工具链输出网络映射后启用完整 `I_total` 路径(届时同一设计 Q_I 会变化,见 §4.4 示例)。 + +**评分**(单边单调成本校准 ψ_cost,越接近下界越好、不惩罚接近下界的设计): + +$$ +Q_I=100\cdot\psi_{cost}(I;\;\tau_{pref}{=}1.25,\;\tau_{fail}{=}1.75)\cdot\big(1-\min(1,S_{cong})\big) +$$ + +- I ≤ 1.25 满分;1.25–1.75 线性降到 0;≥ 1.75 为 0(无拥塞时)。 +- 拥塞严重度(取最大项归一): + +$$ +S_{cong}=\max\Big(\frac{RUDY_{max}}{1.0},\;\frac{EGR_{max}}{20},\;\frac{EGR_{total}}{100}\Big) +$$ + +- 拥塞项是策略性惩罚:S_cong ≥ 1 时 Q_I 直接归 0。拥塞详情同时在诊断里独立呈现(`diag.place.congestion`,§7)。 + +### 3.3 Q_A 面积质量 + +已布局 core 利用率 `core_utilization` 的双侧目标区间校准(欠利用=浪费硅面积,过利用=布通风险): + +$$ +Q_A=100\cdot\psi_{target}(U_{core};\;0.45,\;0.70,\;0.85) +$$ + +- U ∈ [0.45, 0.70] 满分;U < 0.45 按 U/0.45 线性降分;U ∈ (0.70, 0.85] 按 (0.85−U)/0.15 线性降到 0。 +- 注意用的是**已布局 core 利用率**,不是布图规划密度(synthesis 面积 / core 面积,后者只是早期可行性指标 `F_PLAN_DENSITY`,不参与评分)。 + +### 3.4 Q_P 功耗质量 + +只在显式申报功耗预算时计分(策略性预算消耗评估): + +$$ +Q_P=100\cdot\mathrm{clamp}\Big(\frac{P_{budget}-P_{total}}{P_{budget}},\;0,\;1\Big) +$$ + +| 工作点 | Q_P | +|---|---| +| P_total = 0 | 100.0 | +| P_total = 0.5·P_budget | 50.0 | +| P_total ≥ P_budget | 0.0(钳位) | +| 未申报预算 | null(UNKNOWN,排除出总分) | + +- P_total 取**签核 STA** 各 corner 中总功耗(dynamic + leakage,单位 µW)最大者;STA 功耗不可得时回退综合后 post-synthesis STA 功耗估计(报告 `power.source_kind` 标明 `signoff` / `synthesis`)。 +- 预算单位是**瓦**:`qor_power_budget_w = 0.5` 即 0.5 W(§8)。 + +### 3.5 Q_R 鲁棒性质量 + +结构时钟树不平衡 + 多 corner PVT 离散度,等权聚合: + +$$ +Q_R=100\cdot\Big(1-\big[0.5\cdot F_{CTS\_IMBAL}+0.5\cdot\min(1,\Delta_{PVT})\big]\Big) +$$ + +- `F_CTS_BUF_IMBAL = (B_max − B_min) / B_max`(时钟沉端路径缓冲深度不对称,hold 风险代理); +- Δ_PVT = max(Δ_setup, Δ_hold) / T_clk,其中 Δ 为各 corner WS 的极差(现场从逐 corner `qor_summary.json` 归约); +- 某贡献量缺失时**权重在可用项中重归一**(只有 PVT 数据 → w_PVT=1.0;两者皆缺 → Q_R = null)。 + +### 3.6 维度状态映射 + +质量坐标 → 展示状态(timing 维除外,用 §3.1 的 TimingState):`≥80 → PASS`;`60–80 → WATCH`;`<60 → FAIL`。 + +## 4. 总分 Q_summary 与状态色 + +### 4.1 计算规则 + +``` +Q_summary = 0.0 若 Feasibility = PHYSICAL_FAIL(否决不变量) + = null(NOT_RATED) 若 Feasibility ∈ {NOT_VERIFIED, UNKNOWN} + = null(NOT_RATED) 若 PASS 但没有任何可评估维度 + = Σ(w_d · Q_d) / Σ(w_d) 其余情况——只在"已评估维度"上归一 +``` + +**权重重归一**是本方案的关键语义:Q_P 为 null(无预算)时,其余维度权重按和归一,满分设计照样得 100——不会像旧方案那样因缺功耗数据被封顶在 75 分。 + +### 4.2 设计意图档案(profile) + +四个预置权重档案(`qor_profile` 参数选择,§8): + +| Profile | Q_T | Q_I | Q_A | Q_P | Q_R | +|---|---|---|---|---|---| +| `balanced`(默认) | 0.30 | 0.25 | 0.15 | 0.15 | 0.15 | +| `timing_critical` | 0.45 | 0.20 | 0.10 | 0.10 | 0.15 | +| `low_power` | 0.20 | 0.15 | 0.15 | 0.35 | 0.15 | +| `area_optimized` | 0.20 | 0.25 | 0.35 | 0.10 | 0.10 | + +### 4.3 状态色 + +`GREEN ≥90` / `YELLOW ≥75` / `ORANGE ≥60` / `RED <60`;门禁失败 → `FAIL`(分数 0);未评级 → `NOT_RATED`(分数 null)。ECOS Studio Home 的 pass/fail 线是 60 分,恰与 RED 边界重合。 + +### 4.4 完整算例(gcd 参考夹具) + +输入:T_clk = 20 ns(frequency_max = 50 MHz)、WS = +16.622 ns、HPWL = 3143.52 µm、GRWL = 3812.00 µm、RWL = 4315.53 µm、U_core = 0.52、B_max = B_min = 4、Δ_setup = 2.358 ns、Δ_hold = 0.174 ns、无功耗预算。 + +| 维度 | 当前实现(I_place 降级路径) | 完整 I_total 路径(工具链支持后) | +|---|---|---| +| Q_T | WS = 16.622 ≥ τ_gb = 1.0 → **100.0**(OPPORTUNITY) | 同左 | +| Q_I | I_place = 3812/3143.52 = **1.213** ≤ 1.25 → **100.0** | I_total = 4315.53/3143.52 = 1.373 → ψ_cost = (1.75−1.373)/0.5 = 0.754 → **75.4** | +| Q_A | 0.52 ∈ [0.45, 0.70] → **100.0** | 同左 | +| Q_P | 无预算 → **null** | 同左 | +| Q_R | 0.5·0 + 0.5·(2.358/20) = 0.1179 → **94.1** | 同左 | +| 总分 | (0.30+0.25+0.15)·100 + 0.15·94.105,除以 0.85 → **98.96 GREEN** | 77.97/0.85 → **91.7 GREEN** | + +## 5. 可行性门禁(7 条) + +| 门禁 | 阶段 | 输入指标 | 通过谓词 | +|---|---|---|---| +| GATE_DRC | DRC | `drc_count` | == 0 | +| GATE_LVS | LVS | `lvs_count` | == 0 | +| GATE_SETUP_SLACK | STA | `sta_setup_wns`(WS) | ≥ 0.0 ns | +| GATE_HOLD_SLACK | STA | `sta_hold_wns`(WS) | ≥ 0.0 ns | +| GATE_SETUP_NVP | STA | `sta_setup_violation_count` | == 0 | +| GATE_HOLD_NVP | STA | `sta_hold_violation_count` | == 0 | +| GATE_HARDEN_ARTIFACTS | Harden | `harden_artifact_missing_count` | == 0(GDS/LEF/LIB 齐全) | + +要点: + +- **RCX 不是门禁**——寄生提取的 corner 覆盖进**证据指数**(§6),提取缺失会压低证据等级或触发 UNKNOWN,但本身不构成物理失败。 +- 门禁不可用(`unavailable`)分两种:`not_verified`(该步骤未成功执行)与 `corrupt`(步骤成功但证据缺失/损坏)。前者 → 总体 NOT_VERIFIED,后者 → UNKNOWN,都不是 FAIL。 +- 中间步骤的异常(布线中的 DR 违规、布局拥塞溢出)只触发特征级 WATCH/FAIL 诊断,不触发 PHYSICAL_FAIL——只有**最终签核检查**持续失败才算。 +- 时序门禁附带完整 slack 视图:`ws_ns`(有符号)、`wns_ns`(钳位)、`tns_ns`、`nvp`、`worst_corner`。 + +## 6. 证据完整性指数 I_E + +``` +I_E = 100 × E_integrity × E_coverage × E_consistency(乘性合成,故意保守) +状态:HIGH ≥90 / MODERATE ≥70 / LIMITED ≥50 / INSUFFICIENT <50 / NOT_VERIFIED +``` + +| 分量 | 公式 | 说明 | +|---|---|---| +| E_integrity | 1 − (解析失败 + 无效 selector) / (已分析步骤 + 解析失败) | 各步 qor_metrics.json 的解析健康度与溯源有效性 | +| E_coverage | STA corner 装载率与 RCX SPEF 覆盖率的均值 | 装载 = 期望 − 缺失;任一域无期望 → 该域不计 | +| E_consistency | C1–C3 通过率(见下) | 语义一致性检查 | + +一致性检查: + +- **C1**:`RWL ≥ place_hpwl`(网群体兼容时才计,INCOMPATIBLE → 不适用); +- **C2**:`(WS ≥ 0) ⇔ (NVP = 0)`(同 scope/corner/端点总体时才计,防假扣分); +- **C3**:`via_count > 0 ⇒ RWL > 0`(单向拓扑健全性)。 + +补充规则:STA 只输出 setup(无 hold 报告)时,HIGH 降级为 MODERATE。零分母条件一律 NOT_APPLICABLE,不做除零或假满分。 + +## 7. 诊断与干预假设 + +诊断是**对观测的确定性分类**(对当前指标确定成立);干预永远只是**假设**(correlates with,不承诺因果),每条带 `validation_procedure` 要求试跑验证。所有文案使用非因果措辞("margin was consumed across placement",不写 "placement caused")。 + +严重度闭式计算,Tier 1 恒 ≥ 0.80,严格压过质量瓶颈: + +| 诊断类型 | diagnosis_id | 触发 | 严重度 | +|---|---|---|---| +| 签核门禁违规 | `diag.signoff.` | 对应门禁 failed | 0.80 + 0.20·µ(µ 为归一化违规幅度) | +| 质量瓶颈 | `diag.quality.` | 维度分 < 80 | (100 − Q_d)/100 | +| 时序过约束 | `diag.timing.over_provisioned` | WS > 0.20·T_clk | (WS − τ_over)/(T_clk − τ_over) | +| 布局拥塞 | `diag.place.congestion` | S_cong > 0 | min(1, S_cong) | + +µ 的归一基准:时序类 |WS|/τ_fail;DRC count/100;LVS count/50;Harden missing/3;NVP 无端点总数数据,任意违规取满带(evidence-limited,已知限制)。 + +干预假设按三层字典序排序输出: + +1. **Tier 1(可行性阻断)**:按门禁严重度降序——最严重的物理缺陷排最前; +2. **Tier 2(质量瓶颈)**:按瓶颈严重度降序,先于优化机会; +3. **Tier 3(优化机会)**:过约束裕量的降驱动/缩尺寸建议。 + +示例干预与参数旋钮(`parameter_knob` 字段):互连瓶颈 → `route.dr_search_depth`(需试跑重布线验证);面积 → floorplan 利用率目标;鲁棒性 → CTS 平衡与多 corner skew 目标。 + +## 8. 用户可配置参数 + +QoR 读取工作区参数(`/home/params.toml` 的 `[params]` 表,flat snake_case): + +| 参数 | 取值 | 作用 | 缺省 | +|---|---|---|---| +| `qor_profile` | `balanced` / `timing_critical` / `low_power` / `area_optimized` | 总分权重档案(§4.2) | `balanced` | +| `qor_power_budget_w` | 正数,单位**瓦**(如 `0.5`) | 申报功耗预算,激活 Q_P(§3.4) | 不声明 → Q_P = null | +| `frequency_max` | 正数,MHz | 目标频率 → T_clk = 1000/frequency_max,Q_T/Q_R 及 guardband 的基准 | 综合已有参数(见 [配置参考](ecc-config-ref.cn.md)) | + +设置方式(当前版本,参数为工作区局部): + +```toml +# /home/params.toml +[params] +qor_profile = "timing_critical" +qor_power_budget_w = 0.5 +``` + +- 非法值(未知 profile、非正预算)不报错中断:降级为默认并写入报告的 `CONFIG WARNING` 行与 `config_warnings` 字段。 +- `qor_profile` / `qor_power_budget_w` 尚未纳入 `ecc param` 已审核参数表与 GUI 参数面板(规划中);当前直接编辑 `home/params.toml` 后重跑任意一步(或 `ecc report qor`)即可生效。 +- 评分阈值(τ_I_pref=1.25 等)当前为引擎常量,未开放配置;如需工艺校准请向工具链维护者反馈。 + +## 9. 数据来源与指标目录 + +### 9.1 引擎读什么 + +| 来源 | 路径 | 用途 | +|---|---|---| +| 逐步指标 | `/analysis/qor_metrics.json`(schema v3,各步 metrics.py 产出,**保持不变**) | 指标值与溯源 | +| 逐 corner 时序 | `sta_ecc/feature//Cworst/qor_summary.json` | 有符号 setup/hold WS、TNS、NVP;PVT 离散度 | +| 功耗 | `sta_ecc/feature//Cworst/power_summary.json`(回退 `Synthesis_yosys/feature/post_synthesis/power_summary.json`) | P_total | +| 步骤状态 | `home/flow.json` | 只有状态为 `Success` 的步骤参与分析(invalidation 后的陈旧产物不计分) | +| 参数 | `home/params.toml` | profile / 预算 / 频率 | + +同一指标 id 被多步产出时按 `project_role` 优选(final > gate > trend),同优先级后写者胜。 + +### 9.2 引擎消费的指标目录(权威副本见 [metric_registry.py](../analysis/qor/metric_registry.py)) + +综合:`synthesis_cell_area`、`synthesis_cell_count`、`synthesis_wire_count`、`synthesis_power_dynamic_uw`、`synthesis_power_leakage_uw`; +布图:`die_area`、`core_area`、`core_utilization`; +布局:`place_hpwl`、`place_grwl`、`place_flute_wirelength`、`place_congestion_egr_overflow_max/total`、`place_rudy_utilization_max`、`place_lutrudy_utilization_max`; +CTS:`cts_buffer_count`、`cts_inverter_count`、`clock_path_max_buffer/min_buffer`、`clock_wirelength`; +布线:`route_wirelength`、`route_via_count`; +RCX:`rcx_spef_file_count`、`rcx_expected/missing_corner_count`、`rcx_spef_parse_failure_count`、`rcx_worst_total/coupling_capacitance_ff`; +STA:`sta_setup/hold_wns`(有符号 WS)、`sta_setup/hold_tns`、`sta_setup/hold_violation_count`、`sta_frequency_mhz`、`sta_corner_count`、`sta_expected/missing_corner_count`、`sta_worst_setup_corner`; +签核:`drc_count`、`lvs_count`、`harden_artifact_missing_count`。 + +### 9.3 派生特征目录(见 [feature_registry.py](../analysis/qor/feature_registry.py)) + +| 特征 | 公式 | 认识论分类 | +|---|---|---| +| F_SYN_LEAK_FRAC | P_leak / (P_dyn + P_leak) | EXACT_TRANSFORMATION | +| F_PLAN_DENSITY | synthesis_cell_area / core_area | DERIVED_ENGINEERING | +| F_PL_I_PLACE | GRWL / HPWL | DERIVED_ENGINEERING | +| F_PL_CONG_CONC | EGR_max / EGR_total | DERIVED_ENGINEERING | +| F_CTS_BUF_IMBAL | (B_max − B_min) / B_max | DERIVED_ENGINEERING | +| F_RT_I_ROUTE | RWL / GRWL | DERIVED_ENGINEERING(需 MAPPED 兼容) | +| F_RT_I_TOTAL | RWL / HPWL | DERIVED_ENGINEERING(需 MAPPED 兼容) | +| F_RT_VIA_DENSITY | via_count / RWL | DERIVED_ENGINEERING | +| F_RCX_CPL_FRAC | C_cpl / C_tot | DERIVED_ENGINEERING | +| F_STA_HEADROOM | WS / T_clk | DERIVED_ENGINEERING | +| F_STA_FREQ_MARGIN | (F_max − F_target) / F_target | DERIVED_ENGINEERING(仅诊断) | +| F_STA_PVT_SETUP/HOLD/MAX_DISP | 各 corner WS 极差 / T_clk | EMPIRICAL_STATISTICAL | +| S_CONG | max(RUDY/1, EGR_max/20, EGR_total/100) | CALIBRATED_HEURISTIC | + +每个特征记录携带:值(或 null)、公式串、认识论分类、状态、输入指标 id、溯源工件(path + selector)、兼容性契约、解释文本——从顶层诊断可直接回溯到原始报告 selector。 + +## 10. JSON 报告契约(home/qor_report.json) + +### 10.1 结构(节选,真实字段名) + +```json +{ + "schema_version": 3, + "scoring_engine": "qor-v3", + "design": "gcd", + "workspace": "/home/user/ecc-demo/gcd/ws_0001", + "timestamp": "2026-09-09T12:34:56.789012+00:00", + "profile": "balanced", + "tclk_ns": 20.0, + "feasibility": { + "status": "PASS", + "gates": [ + {"id": "GATE_DRC", "stage": "DRC", "state": "passed", + "predicate": "drc_count == 0", "blocks_tapeout": true, + "metrics": ["drc_count"], "availability": null, "timing_slack": null}, + {"id": "GATE_SETUP_SLACK", "stage": "STA", "state": "passed", + "predicate": "sta_setup_wns >= 0.0", "blocks_tapeout": true, + "metrics": ["sta_setup_wns"], "availability": null, + "timing_slack": {"ws_ns": 16.622, "wns_ns": 0.0, "tns_ns": 0.0, + "nvp": 0, "worst_corner": null}} + ] + }, + "evidence": {"index": 100.0, "state": "HIGH", + "integrity": 1.0, "coverage": 1.0, "consistency": 1.0}, + "qor_record": { + "timing": {"key": "timing", "value": 100.0, "state": "OPPORTUNITY", "features": ["…F_STA_HEADROOM 记录…"]}, + "interconnect": {"key": "interconnect", "value": 100.0, "state": "PASS", "features": ["…六个特征记录…"]}, + "area": {"key": "area", "value": 100.0, "state": "PASS", "features": ["…F_PLAN_DENSITY…"]}, + "power": {"key": "power", "value": null, "state": "UNKNOWN", "features": ["…F_SYN_LEAK_FRAC…"]}, + "robustness": {"key": "robustness", "value": 94.1, "state": "PASS", "features": ["…四个特征记录…"]} + }, + "scalar_summary": {"score": 98.96, "status": "GREEN", "profile": "balanced", + "weights": {"timing": 0.30, "interconnect": 0.25, "area": 0.15, + "power": 0.15, "robustness": 0.15}}, + "diagnoses": ["…§7 结构的诊断记录…"], + "inflation": {"i_place": 1.2127, "i_route": null, "i_total": null, + "congestion_severity": 0.0, "compatibility_status": "INCOMPATIBLE"}, + "power": {"total_uw": null, "budget_uw": null, "source_path": null, + "source_kind": null, "corner": null}, + "flow_steps": {"Synthesis": "Success", "Floorplan": "Success", "place": "Success", + "CTS": "Success", "route": "Success", "drc": "Success", + "lvs": "Success", "RCX": "Success", "sta": "Success", + "Harden": "Success"}, + "config_warnings": [] +} +``` + +字段速查:`feasibility`(门禁)、`evidence`(证据)、`qor_record`(五维 + 特征明细)、`scalar_summary`(总分/状态/权重)、`diagnoses`(诊断 + 干预)、`inflation`(膨胀分解与兼容状态)、`power`(功耗观测与来源)、`flow_steps`(写报告时的步骤状态快照)、`config_warnings`(参数降级告警)。 + +### 10.2 ECOS Studio 的消费方式(硬切语义) + +- Studio **不重复计分**:评分/状态/门禁只认 `home/qor_report.json`(校验 `schema_version: 3` 与 `scoring_engine: "qor-v3"`)。 +- **陈旧检测**:报告内 `flow_steps` 快照与当前 `home/flow.json` 不一致(如手改/重跑后报告未刷新)→ 视同无报告,一律 **NOT_RATED**——宁可缺分,不可错分。重跑任意一步即恢复。 +- 逐步指标明细、跨 workspace 指标对比、趋势与回归检测仍读各步 `qor_metrics.json`,仅作数据展示,不产生分数。 + +## 11. 与旧评分方案的区别(迁移说明) + +| 维度 | 旧方案(qor-v3 之前) | 当前方案(qor-v3) | +|---|---|---| +| 阈值 | 绝对值(如 route_wirelength fail=6000 µm),只对标 GCD 量级,跨设计不可比 | 相对膨胀率(I = 实际/几何下界),跨设计可比 | +| 缺维 | 权重不归一,缺功耗维时满分只有 75 | 已评估维度权重归一,满分恒 100 | +| 可行性 | 无否决,DRC 失败可被平均分掩盖 | PHYSICAL_FAIL ⇒ 总分 ≡ 0 | +| 正裕量 | slack ≥ 0 一律 100 分 | WS 连续分化 + 过约束识别(OPPORTUNITY) | +| 缺数据 | "没测到"与"测得 0"不可区分 | 三态语义 + null 维度 | +| 时序计权 | WNS/TNS/frequency/NVP 四指标等权重复计 | 一个连续 Q_T;WNS/TNS/NVP 仅门禁与诊断 | +| 实现 | TS(GUI) 与 Python(CLI) 双份移植,阈值表三份 | ECC 单一实现,GUI 渲染报告 | + +迁移影响(升级时须知): + +1. **分数刻度与颜色语义变化**:旧 75 分(当时的"满分")在新刻度下属 YELLOW;GREEN 线从 40 提到 90。对比历史趋势时注意刻度切换点。 +2. **存量 workspace 置空**:升级前完成、没有 `qor_report.json` 的 workspace 显示 NOT_RATED,**重跑任意一步(或整体重跑)即恢复评分**。 +3. 报告字段 `scoring_engine: "qor-v3"` 可用于程序化辨识新评分。 + +## 12. 常见问题 + +**Q:分数是 NOT_RATED / 显示 "—",为什么?** +任一情形:无 `home/qor_report.json`(升级前完成的旧 workspace);报告陈旧(`flow_steps` 与 flow.json 不一致);可行性为 NOT_VERIFIED(某门禁依赖的步骤没跑成功)/ UNKNOWN(步骤成功但证据缺失或损坏,如 hold 报告缺失——ECC 的 hold STA 输出是可选的);所有维度都不可评估。重跑相关步骤(补出对应证据)即可闭合。 + +**Q:维度分都不低,总分却是 0 / FAIL?** +可行性否决:七条门禁有任一 failed(最常见是 setup/hold slack < 0 或 DRC/LVS 计数非 0)。看报告 `PRIMARY DIAGNOSES` 的 Tier 1 项。 + +**Q:Q_P 为什么是 "— / UNKNOWN"?** +没有申报功耗预算。在 `home/params.toml` 的 `[params]` 加 `qor_power_budget_w = <瓦>` 并重跑。 + +**Q:Q_I 显示 "I_place …(INCOMPATIBLE route side)" 是什么意思?** +当前工具链在 CTS 后没有网络级映射,place→route 线长比值按规范判不兼容、严格 UNKNOWN;Q_I 退化为 place 阶段内部的 I_place 评分(§3.2)。这不是错误,是保守降级。 + +**Q:时序 100 分但状态是 OPPORTUNITY,需要处理吗?** +WS 超过 0.20·T_clk 的过约束提示:设计可能过度缓冲,可尝试缩驱动强度回收面积/漏功(见干预假设 Tier 3)。是否行动取决于项目余量策略。 + +**Q:WS 和 WNS 到底哪个是真的?** +两个都是真的:`ws_ns` 是有符号最差裕量(连续质量与裕量分析用);`wns_ns = min(0, ws_ns)` 是钳位负裕量(门禁与违规幅度用)。ECC 指标名 `sta_setup_wns` 历史上借用 wns 缩写,携带的是有符号值。 + +**Q:阈值(1.25/1.75、0.45/0.70 等)能改吗?** +当前是引擎常量(calibration.py),未开放为用户参数。它们是校准的工程默认值,随版本演进可能调整;对特定工艺的校准需求请反馈给工具链维护者。 + +**Q:`ecc report qor` 和 `home/qor_report.json` 数值会不一致吗?** +正常不会:报告每步成功后自动刷新,CLI 每次现算。若你手改了产物文件或正在并发跑 flow,两者可能短暂不一致;flow 走完后以重跑的 `ecc report qor` 为准。 diff --git a/chipcompiler/docs/ecc-qor-ref.en.md b/chipcompiler/docs/ecc-qor-ref.en.md new file mode 100644 index 000000000..8ad3e9ad6 --- /dev/null +++ b/chipcompiler/docs/ecc-qor-ref.en.md @@ -0,0 +1,483 @@ +# ECC QoR Reference (Quality Scoring · Feasibility Gates · Evidence & Diagnosis) + +This manual documents ECC's current QoR scheme (**ECC-QoR draft 3**, scoring engine id `qor-v3`, report `schema_version: 3`) for engineers using the ECC CLI and ECOS Studio: how scores are computed, how to read the report, which parameters apply, and how to use the diagnoses. All formulas, thresholds, and defaults were verified against the implementation in [chipcompiler/analysis/qor/](../analysis/qor/) (branch `yell/qor_v2`, 2026-09). + +- Command usage and installation → [ECC CLI User Guide](ecc-user-guide.en.md); first run from zero → [Tutorial](ecc-tutorial.en.md) +- Per-step tool configuration → [ECC Flow Tool Configuration Reference](ecc-config-ref.en.md) +- The flow does not have to be complete: QoR evaluates whatever stages have run (unexecuted stages are treated as "not verified", see §2.2). + +## 0. The Big Picture + +```mermaid +graph LR + A["Per-step artifacts
qor_metrics.json / qor_summary.json
power_summary.json"] --> B["ECC QoR engine
qor-v3 (single scorer)"] + B --> C["home/qor_report.json
auto-refreshed after each step"] + C --> D["ECOS Studio
(renderer: 5-dim breakdown / gates / diagnoses)"] + B --> E["ecc report qor
text report → signoff/*.txt"] +``` + +Three design principles explain every line of the output: + +1. **Quality ≠ Feasibility ≠ Evidence** (§2). However high the five quality scores are, a single failed physical signoff gate pins the composite to 0; incomplete evidence yields no score at all (NOT_RATED) rather than a fabricated number. +2. **Relative baselines, comparable across designs** (§3). Interconnect quality is scored as inflation relative to the HPWL geometric lower bound — not against absolute wirelength thresholds — so a 50,000 µm route on a large design compares directly with a 3,000 µm route on a small one. +3. **Missing data is explicitly UNKNOWN** (§2.3). "Measured zero" (e.g., DRC count = 0 — good news) and "not measured" (stage skipped / report corrupt — unknown) are strictly distinguished; the latter never becomes a zero score. + +## 1. Quick Start + +### 1.1 Where to see QoR + +| Entry point | Artifact | Refresh | +|---|---|---| +| Flow engine (automatic) | `/home/qor_report.json` (machine-readable, JSON Schema v3, see §10) | After every successful step (including skips of already-succeeded steps) | +| `ecc report qor` | `/signoff/_qor_report.txt` (human-readable text report) | Rebuilt from current artifacts on every invocation | +| ECOS Studio | Project dashboard QoR card, 5-dimension breakdown, diagnosis list | Reads `home/qor_report.json`; missing or stale → NOT_RATED (§10.2) | + +```bash +ecc report qor --project gcd # writes signoff/gcd_qor_report.txt +ecc report qor --plain # key=value summary (script-friendly) +ecc report qor -o /tmp/qor.txt # custom output path +``` + +`--plain` summary fields: `overall_score` (composite or null), `qor_status` (GREEN/YELLOW/ORANGE/RED/FAIL/NOT_RATED), `gate_status` (feasibility status), `dimensions[]` (per-dimension score/state). + +### 1.2 Text report sample (gcd reference numbers) + +``` +============================================================================== + ECC QoR ANALYSIS REPORT - Design: gcd + Workspace: ~/ecc-demo/gcd/ws_0001 +============================================================================== + FEASIBILITY STATUS : PASS [All 7 Physical Signoff Gates Clean] + EVIDENCE STATE : HIGH [Integrity: 100.0%, Coverage: 100.0%, Consistency: 100.0%] + QoR COMPOSITE : 99.0 / 100 (Status: GREEN, Profile: balanced) +------------------------------------------------------------------------------ + [PHYSICAL QoR RECORD BREAKDOWN] + Timing Quality (Q_T) : 100.0 / 100 [OPPORTUNITY] (WS: +16.622ns, WNS: 0ns) + Interconnect Quality (Q_I): 100.0 / 100 [PASS] (I_place: 1.213 (INCOMPATIBLE route side), S_cong: 0.00) + Area Efficiency (Q_A) : 100.0 / 100 [PASS] (Core Util: 52.0%) + Power Quality (Q_P) : — / 100 [UNKNOWN] (No budget declared) + Robustness (Q_R) : 94.1 / 100 [PASS] (CTS Imbal: 0.0, PVT Spread: 2.36ns) +------------------------------------------------------------------------------ + [PRIMARY DIAGNOSES] + (No active feasibility blockers detected) + + [WATCH & OPPORTUNITY DIAGNOSES] + [OPPORTUNITY] diag.timing.over_provisioned (Severity: 0.79, Confidence: HIGH) + Timing margin (+16.622ns) exceeds the over-provisioning threshold (4ns); the design appears over-constrained. +------------------------------------------------------------------------------ + [PRIORITIZED INTERVENTION HYPOTHESES] + 1. [Tier 3 (Opportunity)] Intervention hypothesis: downsize drive strengths to recover power and area correlated with the excess margin. +============================================================================== +``` + +How to read it: **FEASIBILITY** answers "can it be manufactured" (§5); **EVIDENCE** answers "can the data be trusted" (§6); **QoR COMPOSITE** is the profile-weighted score and status color (§4); the BREAKDOWN is the quality decomposition (§3); DIAGNOSES are deterministic observations and INTERVENTIONS are prioritized hypotheses (§7). + +## 2. Three Semantic Layers: Quality, Feasibility, Evidence + +### 2.1 Physical Quality Qphys (five coordinates) + +``` +Qphys = (Q_T, Q_I, Q_A, Q_P, Q_R), each ∈ [0, 100] or null (explicit UNKNOWN) +``` + +| Dimension | Name | What it evaluates | Inputs | +|---|---|---|---| +| Q_T | Timing quality | Position of the signed worst slack relative to the guardband | `sta_setup_wns` (cross-corner minimum, signed), `frequency_max` | +| Q_I | Interconnect quality | Wirelength inflation over the HPWL geometric lower bound × congestion penalty | `place_hpwl`, `place_grwl`, `route_wirelength`, congestion proxies | +| Q_A | Area quality | Placed core utilization within its target interval | `core_utilization` | +| Q_P | Power quality | Remaining fraction of the declared power budget | `qor_power_budget_w`, STA power | +| Q_P is null whenever no budget is declared | | | | + +When a dimension cannot be evaluated (stage not run, data missing, no budget) it is null with state `UNKNOWN` — **never folded into a zero** — and it silently drops out of the composite via weight re-normalization (§4.1). + +### 2.2 Physical Feasibility (seven signoff gates) + +Feasibility answers "can this layout be signed off", reduced from seven zero-tolerance gates (details in §5): + +``` +PHYSICAL_FAIL (any gate failed) ≻ UNKNOWN (corrupt evidence) ≻ NOT_VERIFIED (stage not executed) ≻ PASS +``` + +- **PHYSICAL_FAIL ⇒ composite is pinned to 0**: excellent area or power can never mask an unmanufacturable chip. +- **Unverified ≠ failed**: a signoff stage that did not run successfully → `NOT_VERIFIED`; a stage that ran but whose evidence is missing or corrupt (e.g., no hold report — ECC's hold STA output is optional) → `UNKNOWN`. Both merely withhold the score (NOT_RATED); neither is a failure. + +### 2.3 Missing-data trichotomy + +| State | Meaning | Example | +|---|---|---| +| Measured zero | The quantity was measured and equals zero — high-confidence evidence | `drc_count = 0`, `egr_total = 0` | +| UNKNOWN | The stage ran but its report is missing/corrupt/unparseable | missing qor_metrics.json → affected dimensions null | +| NOT_APPLICABLE | A prerequisite stage was intentionally omitted or a structural precondition fails | Q_P with no budget; cross-stage ratios with incompatible net populations | + +## 3. How Each Dimension Is Computed + +The default thresholds below are **calibrated engineering values** (CALIBRATED_HEURISTIC / USER_PROJECT_CONSTRAINT), not physical laws. They are centrally defined in [calibration.py](../analysis/qor/calibration.py) and are not user-facing parameters today. + +### 3.1 Q_T — Timing Quality + +Prerequisite: understand **WS vs. WNS** (ECC corrects a long-standing industry naming confusion): + +- **WS (Signed Worst Slack)**: the algebraic slack of the critical path, positive or negative (e.g., +16.622 ns or −0.25 ns). Although ECC's metric id `sta_setup_wns` carries the "wns" suffix, its runtime value is the **signed WS** (cross-corner minimum, never clamped). +- **WNS = min(0, WS)**: used only for gating and violation diagnostics, **never as a continuous quality input** — after clamping, +5 ps and +2 ns are indistinguishable. + +$$ +Q_T=\begin{cases}50\cdot\max\!\big(0,\;1-|WS|/\tau_{fail}\big) & WS<0\\[4pt]50+50\cdot\min\!\big(WS/\tau_{gb},\;1\big) & WS\ge 0\end{cases} +$$ + +- Parameters: τ_gb = 0.05·T_clk (guardband) and τ_fail = 0.20·T_clk; T_clk = 1000 / `frequency_max` (MHz→ns). Missing/invalid `frequency_max` → Q_T = null. +- Properties: continuous at WS = 0 (both one-sided limits are 50); positive margins are continuously differentiated (+5 ps ≈ 55, full 100 at the guardband); negative margins decay linearly to 0. +- Dimension state (TimingState): `WS<0 → FAIL`; `0≤WS<τ_gb → WATCH`; `τ_gb≤WS≤τ_over → PASS`; `WS>τ_over → OPPORTUNITY` (over-constrained; τ_over = 0.20·T_clk — a hint to trade margin for area/power). +- The frequency metric `sta_frequency_mhz` is decoupled from Q_T and appears only as a diagnostic feature. + +### 3.2 Q_I — Interconnect Quality + +**Inflation decomposition** (relative to the HPWL geometric lower bound; HPWL ≤ RSMT is a proven bound): + +$$ +I_{total}=\frac{RWL}{HPWL}=\underbrace{\frac{GRWL}{HPWL}}_{I_{place}}\times\underbrace{\frac{RWL}{GRWL}}_{I_{route}} +$$ + +- `I_place` (global-routing realization overhead): grid discretization, layer constraints, detours; +- `I_route` (detailed-routing inflation): pin access, via transitions, DRC avoidance; +- With a zero/missing denominator the ratio is strictly UNKNOWN — **epsilon padding is prohibited** (it would break the algebraic identity). + +**Cross-stage compatibility (current degraded path)**: `I_route`/`I_total` divide quantities from different stages (place → route), and CTS inserts clock-tree nets between them. The toolchain does not yet emit a net-level mapping, therefore: + +- CTS inserted buffers (or its counts are unknown) → place→route is `INCOMPATIBLE` and `I_route`/`I_total` are strictly UNKNOWN; +- Q_I degrades to scoring `I_place` (intra-placement, one netlist, `EXACT_COMPATIBLE`), and the report states `INCOMPATIBLE route side` explicitly; +- Once the toolchain emits net mappings, the full `I_total` path activates (the same design's Q_I will then change — see the worked example in §4.4). + +**Scoring** (one-sided monotone cost calibration ψ_cost — closer to the bound is better; approaching the bound is never penalized): + +$$ +Q_I=100\cdot\psi_{cost}(I;\;\tau_{pref}{=}1.25,\;\tau_{fail}{=}1.75)\cdot\big(1-\min(1,S_{cong})\big) +$$ + +- I ≤ 1.25 scores full marks; 1.25–1.75 decays linearly to 0; ≥ 1.75 is 0 (without congestion). +- Congestion severity (max of normalized terms): + +$$ +S_{cong}=\max\Big(\frac{RUDY_{max}}{1.0},\;\frac{EGR_{max}}{20},\;\frac{EGR_{total}}{100}\Big) +$$ + +- The congestion factor is a policy penalty: S_cong ≥ 1 drives Q_I to 0. Congestion detail is also surfaced independently as a diagnosis (`diag.place.congestion`, §7). + +### 3.3 Q_A — Area Quality + +Two-sided target-interval calibration of the placed core utilization `core_utilization` (under-utilization wastes silicon; over-utilization risks routability): + +$$ +Q_A=100\cdot\psi_{target}(U_{core};\;0.45,\;0.70,\;0.85) +$$ + +- U ∈ [0.45, 0.70] scores full marks; U < 0.45 decays as U/0.45; U ∈ (0.70, 0.85] decays as (0.85−U)/0.15 to 0. +- Note this is the **placed** core utilization, not the planning density (synthesis area / core area — that is an early indicator `F_PLAN_DENSITY` and is never scored). + +### 3.4 Q_P — Power Quality + +Scored only when a power budget is explicitly declared (policy-level budget-consumption assessment): + +$$ +Q_P=100\cdot\mathrm{clamp}\Big(\frac{P_{budget}-P_{total}}{P_{budget}},\;0,\;1\Big) +$$ + +| Operating point | Q_P | +|---|---| +| P_total = 0 | 100.0 | +| P_total = 0.5·P_budget | 50.0 | +| P_total ≥ P_budget | 0.0 (clamped) | +| No budget declared | null (UNKNOWN, excluded from the composite) | + +- P_total is the **worst (maximum) total signoff power** across STA corners (dynamic + leakage, µW); when signoff power is unavailable it falls back to the post-synthesis STA estimate (the report's `power.source_kind` says `signoff` / `synthesis`). +- The budget unit is **watts**: `qor_power_budget_w = 0.5` means 0.5 W (§8). + +### 3.5 Q_R — Robustness Quality + +Structural clock-tree imbalance + multi-corner PVT dispersion, equal weights: + +$$ +Q_R=100\cdot\Big(1-\big[0.5\cdot F_{CTS\_IMBAL}+0.5\cdot\min(1,\Delta_{PVT})\big]\Big) +$$ + +- `F_CTS_BUF_IMBAL = (B_max − B_min) / B_max` (clock sink path buffer-depth asymmetry; hold-risk proxy); +- Δ_PVT = max(Δ_setup, Δ_hold) / T_clk, where Δ is the cross-corner range of WS (reduced on the fly from the per-corner `qor_summary.json` files); +- Missing contributors **re-normalize over the available ones** (PVT only → w_PVT = 1.0; neither → Q_R = null). + +### 3.6 Dimension state mapping + +Quality coordinate → display state (timing excepted; it uses the TimingState of §3.1): `≥80 → PASS`; `60–80 → WATCH`; `<60 → FAIL`. + +## 4. Composite Q_summary and Status Colors + +### 4.1 Rules + +``` +Q_summary = 0.0 if Feasibility = PHYSICAL_FAIL (veto invariant) + = null (NOT_RATED) if Feasibility ∈ {NOT_VERIFIED, UNKNOWN} + = null (NOT_RATED) if PASS but no dimension is evaluable + = Σ(w_d · Q_d) / Σ(w_d) otherwise — normalized over evaluated dimensions only +``` + +**Weight re-normalization** is the key semantic: when Q_P is null (no budget), the remaining weights re-normalize so a perfect design still scores 100 — unlike the legacy scheme, where a missing power dimension silently capped the score at 75. + +### 4.2 Design-intent profiles + +Four preset weight profiles (selected with the `qor_profile` parameter, §8): + +| Profile | Q_T | Q_I | Q_A | Q_P | Q_R | +|---|---|---|---|---|---| +| `balanced` (default) | 0.30 | 0.25 | 0.15 | 0.15 | 0.15 | +| `timing_critical` | 0.45 | 0.20 | 0.10 | 0.10 | 0.15 | +| `low_power` | 0.20 | 0.15 | 0.15 | 0.35 | 0.15 | +| `area_optimized` | 0.20 | 0.25 | 0.35 | 0.10 | 0.10 | + +### 4.3 Status colors + +`GREEN ≥90` / `YELLOW ≥75` / `ORANGE ≥60` / `RED <60`; a failed gate → `FAIL` (score 0); unevaluated → `NOT_RATED` (score null). ECOS Studio's Home pass/fail line is 60, coinciding exactly with the RED boundary. + +### 4.4 Worked example (gcd reference fixture) + +Inputs: T_clk = 20 ns (frequency_max = 50 MHz), WS = +16.622 ns, HPWL = 3143.52 µm, GRWL = 3812.00 µm, RWL = 4315.53 µm, U_core = 0.52, B_max = B_min = 4, Δ_setup = 2.358 ns, Δ_hold = 0.174 ns, no power budget. + +| Dimension | Current implementation (I_place degraded path) | Full I_total path (once the toolchain supports it) | +|---|---|---| +| Q_T | WS = 16.622 ≥ τ_gb = 1.0 → **100.0** (OPPORTUNITY) | same | +| Q_I | I_place = 3812/3143.52 = **1.213** ≤ 1.25 → **100.0** | I_total = 4315.53/3143.52 = 1.373 → ψ_cost = (1.75−1.373)/0.5 = 0.754 → **75.4** | +| Q_A | 0.52 ∈ [0.45, 0.70] → **100.0** | same | +| Q_P | no budget → **null** | same | +| Q_R | 0.5·0 + 0.5·(2.358/20) = 0.1179 → **94.1** | same | +| Composite | (0.30+0.25+0.15)·100 + 0.15·94.105 over 0.85 → **98.96 GREEN** | 77.97/0.85 → **91.7 GREEN** | + +## 5. Feasibility Gates (7) + +| Gate | Stage | Input metric | Pass predicate | +|---|---|---|---| +| GATE_DRC | DRC | `drc_count` | == 0 | +| GATE_LVS | LVS | `lvs_count` | == 0 | +| GATE_SETUP_SLACK | STA | `sta_setup_wns` (WS) | ≥ 0.0 ns | +| GATE_HOLD_SLACK | STA | `sta_hold_wns` (WS) | ≥ 0.0 ns | +| GATE_SETUP_NVP | STA | `sta_setup_violation_count` | == 0 | +| GATE_HOLD_NVP | STA | `sta_hold_violation_count` | == 0 | +| GATE_HARDEN_ARTIFACTS | Harden | `harden_artifact_missing_count` | == 0 (GDS/LEF/LIB present) | + +Key points: + +- **RCX is not a gate** — parasitic-extraction corner coverage feeds the **evidence index** (§6): missing extraction lowers the evidence grade or triggers UNKNOWN, but it is not by itself a physical failure. +- An unavailable gate comes in two flavors: `not_verified` (the stage did not complete successfully) and `corrupt` (the stage succeeded but its evidence is missing/corrupt). The former → overall NOT_VERIFIED; the latter → UNKNOWN. Neither is FAIL. +- Intermediate-stage anomalies (detailed-routing violations, placement congestion overflow) only trigger feature-level WATCH/FAIL diagnoses — only **final signoff checks** that persist trigger PHYSICAL_FAIL. +- Timing gates carry a full slack view: `ws_ns` (signed), `wns_ns` (clamped), `tns_ns`, `nvp`, `worst_corner`. + +## 6. Evidence Completeness Index I_E + +``` +I_E = 100 × E_integrity × E_coverage × E_consistency (multiplicative, deliberately conservative) +States: HIGH ≥90 / MODERATE ≥70 / LIMITED ≥50 / INSUFFICIENT <50 / NOT_VERIFIED +``` + +| Component | Formula | Meaning | +|---|---|---| +| E_integrity | 1 − (parse failures + invalid selectors) / (analyzed steps + parse failures) | Parse health and provenance validity of the per-step qor_metrics.json | +| E_coverage | mean of the STA corner load ratio and the RCX SPEF coverage ratio | loaded = expected − missing; a domain with no expectation is skipped | +| E_consistency | pass rate of C1–C3 below | Semantic consistency checks | + +Consistency checks: + +- **C1**: `RWL ≥ place_hpwl` (counted only under compatible net populations; INCOMPATIBLE → not applicable); +- **C2**: `(WS ≥ 0) ⇔ (NVP = 0)` (counted only under identical scope/corner/endpoint population, preventing false penalties); +- **C3**: `via_count > 0 ⇒ RWL > 0` (one-way topological sanity). + +Additional rule: STA with setup-only output (no hold report) is downgraded from HIGH to MODERATE. Zero-denominator conditions are uniformly NOT_APPLICABLE — no division by zero, no fake perfect score. + +## 7. Diagnoses and Intervention Hypotheses + +A diagnosis is a **deterministic classification of observations** (it holds with certainty for the current metrics); an intervention is always only a **hypothesis** ("correlates with", never a causal promise), each carrying a `validation_procedure` that demands a trial run. All wording is non-causal ("margin was consumed across placement", never "placement caused"). + +Severities are closed-form; Tier 1 is always ≥ 0.80, strictly outranking quality bottlenecks: + +| Diagnosis type | diagnosis_id | Trigger | Severity | +|---|---|---|---| +| Signoff gate violation | `diag.signoff.` | corresponding gate failed | 0.80 + 0.20·µ (µ = normalized violation magnitude) | +| Quality bottleneck | `diag.quality.` | dimension score < 80 | (100 − Q_d)/100 | +| Timing over-provisioning | `diag.timing.over_provisioned` | WS > 0.20·T_clk | (WS − τ_over)/(T_clk − τ_over) | +| Placement congestion | `diag.place.congestion` | S_cong > 0 | min(1, S_cong) | + +Normalization bases for µ: timing |WS|/τ_fail; DRC count/100; LVS count/50; Harden missing/3; NVP has no endpoint population in the artifacts, so any violation takes the full band (a known evidence-limited simplification). + +Intervention hypotheses are ordered by a three-tier lexicographic policy: + +1. **Tier 1 (feasibility blockers)**: descending gate severity — the most severe physical defect first; +2. **Tier 2 (quality limiters/bottlenecks)**: descending bottleneck severity, ahead of optimization opportunities; +3. **Tier 3 (optimization opportunities)**: downsizing suggestions for over-provisioned margins. + +Example interventions and parameter knobs (the `parameter_knob` field): interconnect bottleneck → `route.dr_search_depth` (requires a trial reroute); area → floorplan utilization targets; robustness → CTS balancing and multi-corner skew targets. + +## 8. User-Configurable Parameters + +QoR reads workspace parameters (the `[params]` table of `/home/params.toml`, flat snake_case): + +| Parameter | Values | Effect | Default | +|---|---|---|---| +| `qor_profile` | `balanced` / `timing_critical` / `low_power` / `area_optimized` | composite weight profile (§4.2) | `balanced` | +| `qor_power_budget_w` | positive number, **watts** (e.g., `0.5`) | declares the power budget, activating Q_P (§3.4) | undeclared → Q_P = null | +| `frequency_max` | positive, MHz | target frequency → T_clk = 1000/frequency_max, the basis for Q_T/Q_R and the guardbands | existing synthesis parameter (see the [configuration reference](ecc-config-ref.en.md)) | + +How to set them (current version; the parameters are workspace-local): + +```toml +# /home/params.toml +[params] +qor_profile = "timing_critical" +qor_power_budget_w = 0.5 +``` + +- Invalid values (unknown profile, non-positive budget) do not abort: they degrade to the default and are surfaced as `CONFIG WARNING` lines in the report and in the `config_warnings` field. +- `qor_profile` / `qor_power_budget_w` are not yet part of the reviewed `ecc param` vocabulary or the GUI parameter panel (planned); for now edit `home/params.toml` and rerun any step (or run `ecc report qor`) to apply. +- Scoring thresholds (τ_I_pref = 1.25 etc.) are engine constants, not configurable; contact the toolchain maintainers for technology-specific calibration. + +## 9. Data Sources and Metric Catalog + +### 9.1 What the engine reads + +| Source | Path | Purpose | +|---|---|---| +| Per-step metrics | `/analysis/qor_metrics.json` (schema v3, emitted by each step's metrics.py — **unchanged**) | metric values and provenance | +| Per-corner timing | `sta_ecc/feature//Cworst/qor_summary.json` | signed setup/hold WS, TNS, NVP; PVT dispersion | +| Power | `sta_ecc/feature//Cworst/power_summary.json` (falls back to `Synthesis_yosys/feature/post_synthesis/power_summary.json`) | P_total | +| Step states | `home/flow.json` | only steps whose state is `Success` are analyzed (stale artifacts after invalidation never score) | +| Parameters | `home/params.toml` | profile / budget / frequency | + +When several steps emit the same metric id, selection prefers `project_role` (final > gate > trend) and, at equal priority, the later step wins. + +### 9.2 Metric catalog consumed by the engine (authoritative copy in [metric_registry.py](../analysis/qor/metric_registry.py)) + +Synthesis: `synthesis_cell_area`, `synthesis_cell_count`, `synthesis_wire_count`, `synthesis_power_dynamic_uw`, `synthesis_power_leakage_uw`; +Floorplan: `die_area`, `core_area`, `core_utilization`; +Placement: `place_hpwl`, `place_grwl`, `place_flute_wirelength`, `place_congestion_egr_overflow_max/total`, `place_rudy_utilization_max`, `place_lutrudy_utilization_max`; +CTS: `cts_buffer_count`, `cts_inverter_count`, `clock_path_max_buffer/min_buffer`, `clock_wirelength`; +Routing: `route_wirelength`, `route_via_count`; +RCX: `rcx_spef_file_count`, `rcx_expected/missing_corner_count`, `rcx_spef_parse_failure_count`, `rcx_worst_total/coupling_capacitance_ff`; +STA: `sta_setup/hold_wns` (signed WS), `sta_setup/hold_tns`, `sta_setup/hold_violation_count`, `sta_frequency_mhz`, `sta_corner_count`, `sta_expected/missing_corner_count`, `sta_worst_setup_corner`; +Signoff: `drc_count`, `lvs_count`, `harden_artifact_missing_count`. + +### 9.3 Derived feature catalog (see [feature_registry.py](../analysis/qor/feature_registry.py)) + +| Feature | Formula | Epistemic class | +|---|---|---| +| F_SYN_LEAK_FRAC | P_leak / (P_dyn + P_leak) | EXACT_TRANSFORMATION | +| F_PLAN_DENSITY | synthesis_cell_area / core_area | DERIVED_ENGINEERING | +| F_PL_I_PLACE | GRWL / HPWL | DERIVED_ENGINEERING | +| F_PL_CONG_CONC | EGR_max / EGR_total | DERIVED_ENGINEERING | +| F_CTS_BUF_IMBAL | (B_max − B_min) / B_max | DERIVED_ENGINEERING | +| F_RT_I_ROUTE | RWL / GRWL | DERIVED_ENGINEERING (requires MAPPED compatibility) | +| F_RT_I_TOTAL | RWL / HPWL | DERIVED_ENGINEERING (requires MAPPED compatibility) | +| F_RT_VIA_DENSITY | via_count / RWL | DERIVED_ENGINEERING | +| F_RCX_CPL_FRAC | C_cpl / C_tot | DERIVED_ENGINEERING | +| F_STA_HEADROOM | WS / T_clk | DERIVED_ENGINEERING | +| F_STA_FREQ_MARGIN | (F_max − F_target) / F_target | DERIVED_ENGINEERING (diagnostic only) | +| F_STA_PVT_SETUP/HOLD/MAX_DISP | cross-corner WS range / T_clk | EMPIRICAL_STATISTICAL | +| S_CONG | max(RUDY/1, EGR_max/20, EGR_total/100) | CALIBRATED_HEURISTIC | + +Every feature record carries: value (or null), formula string, epistemic class, state, input metric ids, provenance artifacts (path + selector), the compatibility contract, and an interpretation — top-level diagnoses trace all the way back to raw report selectors. + +## 10. JSON Report Contract (home/qor_report.json) + +### 10.1 Structure (abridged; real field names) + +```json +{ + "schema_version": 3, + "scoring_engine": "qor-v3", + "design": "gcd", + "workspace": "/home/user/ecc-demo/gcd/ws_0001", + "timestamp": "2026-09-09T12:34:56.789012+00:00", + "profile": "balanced", + "tclk_ns": 20.0, + "feasibility": { + "status": "PASS", + "gates": [ + {"id": "GATE_DRC", "stage": "DRC", "state": "passed", + "predicate": "drc_count == 0", "blocks_tapeout": true, + "metrics": ["drc_count"], "availability": null, "timing_slack": null}, + {"id": "GATE_SETUP_SLACK", "stage": "STA", "state": "passed", + "predicate": "sta_setup_wns >= 0.0", "blocks_tapeout": true, + "metrics": ["sta_setup_wns"], "availability": null, + "timing_slack": {"ws_ns": 16.622, "wns_ns": 0.0, "tns_ns": 0.0, + "nvp": 0, "worst_corner": null}} + ] + }, + "evidence": {"index": 100.0, "state": "HIGH", + "integrity": 1.0, "coverage": 1.0, "consistency": 1.0}, + "qor_record": { + "timing": {"key": "timing", "value": 100.0, "state": "OPPORTUNITY", "features": ["…F_STA_HEADROOM record…"]}, + "interconnect": {"key": "interconnect", "value": 100.0, "state": "PASS", "features": ["…six feature records…"]}, + "area": {"key": "area", "value": 100.0, "state": "PASS", "features": ["…F_PLAN_DENSITY…"]}, + "power": {"key": "power", "value": null, "state": "UNKNOWN", "features": ["…F_SYN_LEAK_FRAC…"]}, + "robustness": {"key": "robustness", "value": 94.1, "state": "PASS", "features": ["…four feature records…"]} + }, + "scalar_summary": {"score": 98.96, "status": "GREEN", "profile": "balanced", + "weights": {"timing": 0.30, "interconnect": 0.25, "area": 0.15, + "power": 0.15, "robustness": 0.15}}, + "diagnoses": ["…diagnosis records per §7…"], + "inflation": {"i_place": 1.2127, "i_route": null, "i_total": null, + "congestion_severity": 0.0, "compatibility_status": "INCOMPATIBLE"}, + "power": {"total_uw": null, "budget_uw": null, "source_path": null, + "source_kind": null, "corner": null}, + "flow_steps": {"Synthesis": "Success", "Floorplan": "Success", "place": "Success", + "CTS": "Success", "route": "Success", "drc": "Success", + "lvs": "Success", "RCX": "Success", "sta": "Success", + "Harden": "Success"}, + "config_warnings": [] +} +``` + +Field quick reference: `feasibility` (gates), `evidence`, `qor_record` (five dimensions + feature detail), `scalar_summary` (score/status/weights), `diagnoses` (diagnoses + interventions), `inflation` (decomposition + compatibility), `power` (observation + source), `flow_steps` (step-state snapshot at write time), `config_warnings` (parameter-degradation warnings). + +### 10.2 How ECOS Studio consumes it (hard-cut semantics) + +- Studio **never re-scores**: scores, statuses, and gates come only from `home/qor_report.json` (validated for `schema_version: 3` and `scoring_engine: "qor-v3"`). +- **Staleness detection**: when the report's embedded `flow_steps` snapshot disagrees with the current `home/flow.json` (e.g., artifacts changed after the report was written), the report is treated as absent → **NOT_RATED** — prefer no score over a wrong score. Rerunning any step restores it. +- Per-step metric detail, cross-workspace metric comparison, trend, and regression detection still read the per-step `qor_metrics.json`; they are pure data display and never produce scores. + +## 11. Differences From the Legacy Scheme (Migration Notes) + +| Aspect | Legacy (pre qor-v3) | Current (qor-v3) | +|---|---|---| +| Thresholds | absolute values (e.g., route_wirelength fail = 6000 µm), GCD-scale only, not comparable across designs | relative inflation (I = actual / geometric bound), comparable across designs | +| Missing dimensions | no weight re-normalization; a missing power dimension capped the ceiling at 75 | re-normalized over evaluated dimensions; the ceiling is always 100 | +| Feasibility | no veto; a DRC failure could hide behind the average | PHYSICAL_FAIL ⇒ composite ≡ 0 | +| Positive slack | any slack ≥ 0 scored 100 | continuous WS differentiation + over-constraint detection (OPPORTUNITY) | +| Missing data | "not measured" and "measured zero" indistinguishable | trichotomy + null dimensions | +| Timing weighting | WNS/TNS/frequency/NVP equally weighted (one fact counted 4×) | a single continuous Q_T; WNS/TNS/NVP for gating and diagnostics only | +| Implementation | a TS (GUI) and a Python (CLI) port of the same scorer; three threshold tables | one implementation in ECC; the GUI renders the report | + +Migration impact (to know before upgrading): + +1. **Score scale and color semantics change**: a legacy 75 (the then-ceiling) lands in YELLOW on the new scale; the GREEN line moves from 40 to 90. Watch for the scale switch when comparing historical trends. +2. **Legacy workspaces are blanked**: workspaces completed before the upgrade (no `qor_report.json`) show NOT_RATED; **rerunning any step (or the whole flow) restores scoring**. +3. The report field `scoring_engine: "qor-v3"` identifies the new scorer programmatically. + +## 12. FAQ + +**Q: The score is NOT_RATED / shows "—". Why?** +One of: no `home/qor_report.json` (legacy workspace completed before the upgrade); a stale report (`flow_steps` disagrees with flow.json); feasibility is NOT_VERIFIED (a gate's stage did not run successfully) or UNKNOWN (the stage ran but its evidence is missing or corrupt — e.g., no hold report; ECC's hold STA output is optional); or no dimension is evaluable. Rerun the affected steps (producing the missing evidence) to close it out. + +**Q: The dimension scores look fine but the composite is 0 / FAIL?** +The feasibility veto: one of the seven gates failed (most often setup/hold slack < 0, or a nonzero DRC/LVS count). See the Tier 1 entries under `PRIMARY DIAGNOSES`. + +**Q: Why is Q_P "— / UNKNOWN"?** +No power budget is declared. Add `qor_power_budget_w = ` to the `[params]` table of `home/params.toml` and rerun. + +**Q: Q_I shows "I_place … (INCOMPATIBLE route side)". What does that mean?** +The toolchain emits no net-level mapping after CTS, so place→route wirelength ratios are conservatively INCOMPATIBLE and strictly UNKNOWN; Q_I degrades to the intra-placement I_place score (§3.2). This is a deliberate degradation, not an error. + +**Q: Timing scores 100 but the state is OPPORTUNITY. Should I act?** +WS exceeds 0.20·T_clk — an over-constraint hint: the design may be over-buffered; consider downsizing drive strengths to recover area/leakage (see the Tier 3 intervention). Whether to act depends on the project's margin policy. + +**Q: Which is real, WS or WNS?** +Both: `ws_ns` is the signed worst slack (used for continuous quality and headroom analysis); `wns_ns = min(0, ws_ns)` is the clamped negative slack (used for gating and violation magnitude). ECC's metric id `sta_setup_wns` historically borrows the wns abbreviation but carries the signed value. + +**Q: Can I change the thresholds (1.25/1.75, 0.45/0.70, …)?** +They are engine constants today (calibration.py), not user parameters. They are calibrated engineering defaults and may evolve between releases; route technology-specific calibration requests to the toolchain maintainers. + +**Q: Can `ecc report qor` and `home/qor_report.json` disagree?** +Not normally: the report refreshes after every successful step and the CLI recomputes on the fly. If you hand-edit artifact files or run the flow concurrently, they may diverge briefly; after the flow finishes, a rerun of `ecc report qor` is authoritative. From b658b1f057ad789301a0dc1170b3c614a22db6f2 Mon Sep 17 00:00:00 2001 From: Yell-walkalone <12112088@qq.com> Date: Sat, 12 Sep 2026 17:51:31 +0800 Subject: [PATCH 5/7] fix(qor): track postFloorplan step after floorplan split Rebase adaptation: main split the Floorplan flow step into preFloorplan/postFloorplan, so the QoR v3 engine must score the postFloorplan step and directory (postFloorplan_ecc) that owns the scored analysis payload, matching the retired _STEP_ENUM_TO_LABEL mapping. --- chipcompiler/analysis/qor/metric_registry.py | 2 +- chipcompiler/docs/ecc-qor-ref.cn.md | 2 +- chipcompiler/docs/ecc-qor-ref.en.md | 2 +- test/analysis/qor/helpers.py | 6 +++--- test/analysis/qor/test_feasibility.py | 2 +- test/analysis/qor/test_loader.py | 4 ++-- test/analysis/qor/test_reference_gcd.py | 6 +++--- test/analysis/qor/test_scoring.py | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/chipcompiler/analysis/qor/metric_registry.py b/chipcompiler/analysis/qor/metric_registry.py index 8ac20e40c..13fed1188 100644 --- a/chipcompiler/analysis/qor/metric_registry.py +++ b/chipcompiler/analysis/qor/metric_registry.py @@ -61,7 +61,7 @@ # Steps whose Success state and analysis payload the engine consumes. SCORED_STEP_VALUES = ( "Synthesis", - "Floorplan", + "postFloorplan", "place", "CTS", "legalization", diff --git a/chipcompiler/docs/ecc-qor-ref.cn.md b/chipcompiler/docs/ecc-qor-ref.cn.md index 3bebf47a0..0e0900987 100644 --- a/chipcompiler/docs/ecc-qor-ref.cn.md +++ b/chipcompiler/docs/ecc-qor-ref.cn.md @@ -422,7 +422,7 @@ STA:`sta_setup/hold_wns`(有符号 WS)、`sta_setup/hold_tns`、`sta_setup "congestion_severity": 0.0, "compatibility_status": "INCOMPATIBLE"}, "power": {"total_uw": null, "budget_uw": null, "source_path": null, "source_kind": null, "corner": null}, - "flow_steps": {"Synthesis": "Success", "Floorplan": "Success", "place": "Success", + "flow_steps": {"Synthesis": "Success", "postFloorplan": "Success", "place": "Success", "CTS": "Success", "route": "Success", "drc": "Success", "lvs": "Success", "RCX": "Success", "sta": "Success", "Harden": "Success"}, diff --git a/chipcompiler/docs/ecc-qor-ref.en.md b/chipcompiler/docs/ecc-qor-ref.en.md index 8ad3e9ad6..17d1d5de1 100644 --- a/chipcompiler/docs/ecc-qor-ref.en.md +++ b/chipcompiler/docs/ecc-qor-ref.en.md @@ -422,7 +422,7 @@ Every feature record carries: value (or null), formula string, epistemic class, "congestion_severity": 0.0, "compatibility_status": "INCOMPATIBLE"}, "power": {"total_uw": null, "budget_uw": null, "source_path": null, "source_kind": null, "corner": null}, - "flow_steps": {"Synthesis": "Success", "Floorplan": "Success", "place": "Success", + "flow_steps": {"Synthesis": "Success", "postFloorplan": "Success", "place": "Success", "CTS": "Success", "route": "Success", "drc": "Success", "lvs": "Success", "RCX": "Success", "sta": "Success", "Harden": "Success"}, diff --git a/test/analysis/qor/helpers.py b/test/analysis/qor/helpers.py index 47d6e3feb..dd0c9f3b7 100644 --- a/test/analysis/qor/helpers.py +++ b/test/analysis/qor/helpers.py @@ -47,7 +47,7 @@ def _all_success(): step: SUCCESS for step in ( "Synthesis", - "Floorplan", + "postFloorplan", "place", "CTS", "route", @@ -64,8 +64,8 @@ def gcd_metrics() -> dict: """Reference GCD fixture inputs (spec §12.1).""" records = [ make_metric("synthesis_cell_area", 800.0, step="Synthesis"), - make_metric("core_area", 1538.46, step="Floorplan"), - make_metric("core_utilization", 0.52, step="Floorplan"), + make_metric("core_area", 1538.46, step="postFloorplan"), + make_metric("core_utilization", 0.52, step="postFloorplan"), make_metric("place_hpwl", 3143.52, step="place"), make_metric("place_grwl", 3812.00, step="place"), make_metric("place_rudy_utilization_max", 0.0, step="place"), diff --git a/test/analysis/qor/test_feasibility.py b/test/analysis/qor/test_feasibility.py index 97a49d0ab..d673db8d5 100644 --- a/test/analysis/qor/test_feasibility.py +++ b/test/analysis/qor/test_feasibility.py @@ -5,7 +5,7 @@ step: "Success" for step in ( "Synthesis", - "Floorplan", + "postFloorplan", "place", "CTS", "route", diff --git a/test/analysis/qor/test_loader.py b/test/analysis/qor/test_loader.py index 4c6be77ff..319560c2a 100644 --- a/test/analysis/qor/test_loader.py +++ b/test/analysis/qor/test_loader.py @@ -50,7 +50,7 @@ def _write_step_payload(root, directory, metrics): _STEP_DIRECTORIES = { "Synthesis": "Synthesis_yosys", - "Floorplan": "Floorplan_ecc", + "postFloorplan": "postFloorplan_ecc", "place": "place_dreamplace", "CTS": "CTS_ecc", "route": "route_ecc", @@ -90,7 +90,7 @@ class _Flow: _FULL_FLOW = { "Synthesis": SUCCESS, - "Floorplan": SUCCESS, + "postFloorplan": SUCCESS, "place": SUCCESS, "CTS": SUCCESS, "route": SUCCESS, diff --git a/test/analysis/qor/test_reference_gcd.py b/test/analysis/qor/test_reference_gcd.py index 6426e7b2a..719d54f20 100644 --- a/test/analysis/qor/test_reference_gcd.py +++ b/test/analysis/qor/test_reference_gcd.py @@ -27,7 +27,7 @@ _FLOW_STEPS = { "Synthesis": SUCCESS, - "Floorplan": SUCCESS, + "postFloorplan": SUCCESS, "place": SUCCESS, "CTS": SUCCESS, "route": SUCCESS, @@ -88,7 +88,7 @@ def _make_gcd_workspace(tmp_path): directory_by_step = { "Synthesis": "Synthesis_yosys", - "Floorplan": "Floorplan_ecc", + "postFloorplan": "postFloorplan_ecc", "place": "place_dreamplace", "CTS": "CTS_ecc", "route": "route_ecc", @@ -100,7 +100,7 @@ def _make_gcd_workspace(tmp_path): } payloads = { "Synthesis": [_metric("synthesis_cell_area", 800.0, "trend", "um^2")], - "Floorplan": [ + "postFloorplan": [ _metric("core_area", 1538.46, "trend", "um^2"), _metric("core_utilization", 0.52, "trend"), ], diff --git a/test/analysis/qor/test_scoring.py b/test/analysis/qor/test_scoring.py index 20a4b872a..18bdcfbe5 100644 --- a/test/analysis/qor/test_scoring.py +++ b/test/analysis/qor/test_scoring.py @@ -44,7 +44,7 @@ def test_physical_fail_vetoes_to_zero(self): assert dimensions["area"].value == pytest.approx(100.0) def test_not_verified_is_not_rated(self): - states = {step: "Success" for step in ("Synthesis", "Floorplan", "place", "CTS", "route")} + states = {step: "Success" for step in ("Synthesis", "postFloorplan", "place", "CTS", "route")} states["drc"] = "Unstart" summary, _ = _summary(make_inputs(gcd_metrics(), states)) assert summary.score is None From 0f8be3da85fe6571c00fb2baf7f17f70b3bcdb27 Mon Sep 17 00:00:00 2001 From: Yell-walkalone <12112088@qq.com> Date: Mon, 14 Sep 2026 10:18:31 +0800 Subject: [PATCH 6/7] style(qor): wrap long states comprehension in scoring test --- test/analysis/qor/test_scoring.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/analysis/qor/test_scoring.py b/test/analysis/qor/test_scoring.py index 18bdcfbe5..869264b9f 100644 --- a/test/analysis/qor/test_scoring.py +++ b/test/analysis/qor/test_scoring.py @@ -44,7 +44,9 @@ def test_physical_fail_vetoes_to_zero(self): assert dimensions["area"].value == pytest.approx(100.0) def test_not_verified_is_not_rated(self): - states = {step: "Success" for step in ("Synthesis", "postFloorplan", "place", "CTS", "route")} + states = { + step: "Success" for step in ("Synthesis", "postFloorplan", "place", "CTS", "route") + } states["drc"] = "Unstart" summary, _ = _summary(make_inputs(gcd_metrics(), states)) assert summary.score is None From feb8c85e16be25552213f4912d9ed370dc50a817 Mon Sep 17 00:00:00 2001 From: Yell-walkalone <12112088@qq.com> Date: Mon, 14 Sep 2026 11:06:38 +0800 Subject: [PATCH 7/7] docs(qor): unify version naming to V3 and drop internal references --- chipcompiler/docs/ecc-qor-ref.cn.md | 20 ++++++++++---------- chipcompiler/docs/ecc-qor-ref.en.md | 20 ++++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/chipcompiler/docs/ecc-qor-ref.cn.md b/chipcompiler/docs/ecc-qor-ref.cn.md index 0e0900987..58cc2817e 100644 --- a/chipcompiler/docs/ecc-qor-ref.cn.md +++ b/chipcompiler/docs/ecc-qor-ref.cn.md @@ -1,6 +1,6 @@ # ECC QoR 参考手册(质量评分 · 可行性门禁 · 证据与诊断) -本文整理 ECC 当前 QoR 方案(**ECC-QoR draft 3**,评分引擎标识 `qor-v3`,报告 `schema_version: 3`),面向使用 ECC CLI 与 ECOS Studio 的工程师:分数怎么算、报告怎么读、参数怎么配、诊断怎么用。全部公式、阈值与默认值均核对自实现源码 [chipcompiler/analysis/qor/](../analysis/qor/)(分支 `yell/qor_v2`,2026-09)。 +本文整理 ECC 当前 QoR 方案(**ECC-QoR V3**,报告 `schema_version: 3`),面向使用 ECC CLI 与 ECOS Studio 的工程师:分数怎么算、报告怎么读、参数怎么配、诊断怎么用。全部公式、阈值与默认值均与当前实现一致。 - 命令用法与安装 → [ECC CLI 用户指南](ecc-user-guide.cn.md);从零上手 → [入门教程](ecc-tutorial.cn.md) - 每步工具配置参数 → [ECC Flow 工具配置参考](ecc-config-ref.cn.md) @@ -10,7 +10,7 @@ ```mermaid graph LR - A["各步骤产物
qor_metrics.json / qor_summary.json
power_summary.json"] --> B["ECC QoR 引擎
qor-v3(唯一计算方)"] + A["各步骤产物
qor_metrics.json / qor_summary.json
power_summary.json"] --> B["ECC QoR V3 引擎
(唯一计算方)"] B --> C["home/qor_report.json
每步成功后自动刷新"] C --> D["ECOS Studio
(渲染方:五维分解/门禁/诊断)"] B --> E["ecc report qor
文本报告 → signoff/*.txt"] @@ -28,7 +28,7 @@ graph LR | 入口 | 产物 | 刷新时机 | |---|---|---| -| flow 引擎自动写 | `/home/qor_report.json`(机器可读,JSON Schema v3,见 §10) | 每个步骤成功后(含跳过已成功步骤时)自动刷新 | +| flow 引擎自动写 | `/home/qor_report.json`(机器可读,schema_version 3,见 §10) | 每个步骤成功后(含跳过已成功步骤时)自动刷新 | | `ecc report qor` | `/signoff/_qor_report.txt`(人类可读文本报告) | 每次执行都按当前产物现算快照 | | ECOS Studio | 项目看板 QoR 卡、五维分解、诊断列表 | 读取 `home/qor_report.json`,无报告或陈旧时显示 NOT_RATED(§10.2) | @@ -111,7 +111,7 @@ PHYSICAL_FAIL(任一门禁 failed)≻ UNKNOWN(证据损坏)≻ NOT_VERIF ## 3. 五维怎么算 -以下公式中的默认阈值都是**校准的工程经验值**(CALIBRATED_HEURISTIC / USER_PROJECT_CONSTRAINT),不是物理定律,集中定义在 [calibration.py](../analysis/qor/calibration.py),当前未开放为用户参数。 +以下公式中的默认阈值都是**校准的工程经验值**(CALIBRATED_HEURISTIC / USER_PROJECT_CONSTRAINT),不是物理定律,当前未开放为用户参数。 ### 3.1 Q_T 时序质量 @@ -340,7 +340,7 @@ qor_power_budget_w = 0.5 | 来源 | 路径 | 用途 | |---|---|---| -| 逐步指标 | `/analysis/qor_metrics.json`(schema v3,各步 metrics.py 产出,**保持不变**) | 指标值与溯源 | +| 逐步指标 | `/analysis/qor_metrics.json`(schema_version 3,由各步骤产出,**保持不变**) | 指标值与溯源 | | 逐 corner 时序 | `sta_ecc/feature//Cworst/qor_summary.json` | 有符号 setup/hold WS、TNS、NVP;PVT 离散度 | | 功耗 | `sta_ecc/feature//Cworst/power_summary.json`(回退 `Synthesis_yosys/feature/post_synthesis/power_summary.json`) | P_total | | 步骤状态 | `home/flow.json` | 只有状态为 `Success` 的步骤参与分析(invalidation 后的陈旧产物不计分) | @@ -348,7 +348,7 @@ qor_power_budget_w = 0.5 同一指标 id 被多步产出时按 `project_role` 优选(final > gate > trend),同优先级后写者胜。 -### 9.2 引擎消费的指标目录(权威副本见 [metric_registry.py](../analysis/qor/metric_registry.py)) +### 9.2 引擎消费的指标目录 综合:`synthesis_cell_area`、`synthesis_cell_count`、`synthesis_wire_count`、`synthesis_power_dynamic_uw`、`synthesis_power_leakage_uw`; 布图:`die_area`、`core_area`、`core_utilization`; @@ -359,7 +359,7 @@ RCX:`rcx_spef_file_count`、`rcx_expected/missing_corner_count`、`rcx_spef_pa STA:`sta_setup/hold_wns`(有符号 WS)、`sta_setup/hold_tns`、`sta_setup/hold_violation_count`、`sta_frequency_mhz`、`sta_corner_count`、`sta_expected/missing_corner_count`、`sta_worst_setup_corner`; 签核:`drc_count`、`lvs_count`、`harden_artifact_missing_count`。 -### 9.3 派生特征目录(见 [feature_registry.py](../analysis/qor/feature_registry.py)) +### 9.3 派生特征目录 | 特征 | 公式 | 认识论分类 | |---|---|---| @@ -440,7 +440,7 @@ STA:`sta_setup/hold_wns`(有符号 WS)、`sta_setup/hold_tns`、`sta_setup ## 11. 与旧评分方案的区别(迁移说明) -| 维度 | 旧方案(qor-v3 之前) | 当前方案(qor-v3) | +| 维度 | 旧方案(V3 之前) | 当前方案(V3) | |---|---|---| | 阈值 | 绝对值(如 route_wirelength fail=6000 µm),只对标 GCD 量级,跨设计不可比 | 相对膨胀率(I = 实际/几何下界),跨设计可比 | | 缺维 | 权重不归一,缺功耗维时满分只有 75 | 已评估维度权重归一,满分恒 100 | @@ -448,7 +448,7 @@ STA:`sta_setup/hold_wns`(有符号 WS)、`sta_setup/hold_tns`、`sta_setup | 正裕量 | slack ≥ 0 一律 100 分 | WS 连续分化 + 过约束识别(OPPORTUNITY) | | 缺数据 | "没测到"与"测得 0"不可区分 | 三态语义 + null 维度 | | 时序计权 | WNS/TNS/frequency/NVP 四指标等权重复计 | 一个连续 Q_T;WNS/TNS/NVP 仅门禁与诊断 | -| 实现 | TS(GUI) 与 Python(CLI) 双份移植,阈值表三份 | ECC 单一实现,GUI 渲染报告 | +| 计分一致性 | GUI 与 CLI 各自维护一份计分实现,阈值表三份,可能漂移 | ECC 单一实现,GUI 只渲染报告 | 迁移影响(升级时须知): @@ -477,7 +477,7 @@ WS 超过 0.20·T_clk 的过约束提示:设计可能过度缓冲,可尝试 两个都是真的:`ws_ns` 是有符号最差裕量(连续质量与裕量分析用);`wns_ns = min(0, ws_ns)` 是钳位负裕量(门禁与违规幅度用)。ECC 指标名 `sta_setup_wns` 历史上借用 wns 缩写,携带的是有符号值。 **Q:阈值(1.25/1.75、0.45/0.70 等)能改吗?** -当前是引擎常量(calibration.py),未开放为用户参数。它们是校准的工程默认值,随版本演进可能调整;对特定工艺的校准需求请反馈给工具链维护者。 +当前是引擎常量,未开放为用户参数。它们是校准的工程默认值,随版本演进可能调整;对特定工艺的校准需求请反馈给工具链维护者。 **Q:`ecc report qor` 和 `home/qor_report.json` 数值会不一致吗?** 正常不会:报告每步成功后自动刷新,CLI 每次现算。若你手改了产物文件或正在并发跑 flow,两者可能短暂不一致;flow 走完后以重跑的 `ecc report qor` 为准。 diff --git a/chipcompiler/docs/ecc-qor-ref.en.md b/chipcompiler/docs/ecc-qor-ref.en.md index 17d1d5de1..284f38880 100644 --- a/chipcompiler/docs/ecc-qor-ref.en.md +++ b/chipcompiler/docs/ecc-qor-ref.en.md @@ -1,6 +1,6 @@ # ECC QoR Reference (Quality Scoring · Feasibility Gates · Evidence & Diagnosis) -This manual documents ECC's current QoR scheme (**ECC-QoR draft 3**, scoring engine id `qor-v3`, report `schema_version: 3`) for engineers using the ECC CLI and ECOS Studio: how scores are computed, how to read the report, which parameters apply, and how to use the diagnoses. All formulas, thresholds, and defaults were verified against the implementation in [chipcompiler/analysis/qor/](../analysis/qor/) (branch `yell/qor_v2`, 2026-09). +This manual documents ECC's current QoR scheme (**ECC-QoR V3**, report `schema_version: 3`) for engineers using the ECC CLI and ECOS Studio: how scores are computed, how to read the report, which parameters apply, and how to use the diagnoses. All formulas, thresholds, and defaults match the current implementation. - Command usage and installation → [ECC CLI User Guide](ecc-user-guide.en.md); first run from zero → [Tutorial](ecc-tutorial.en.md) - Per-step tool configuration → [ECC Flow Tool Configuration Reference](ecc-config-ref.en.md) @@ -10,7 +10,7 @@ This manual documents ECC's current QoR scheme (**ECC-QoR draft 3**, scoring eng ```mermaid graph LR - A["Per-step artifacts
qor_metrics.json / qor_summary.json
power_summary.json"] --> B["ECC QoR engine
qor-v3 (single scorer)"] + A["Per-step artifacts
qor_metrics.json / qor_summary.json
power_summary.json"] --> B["ECC QoR V3 engine
(single scorer)"] B --> C["home/qor_report.json
auto-refreshed after each step"] C --> D["ECOS Studio
(renderer: 5-dim breakdown / gates / diagnoses)"] B --> E["ecc report qor
text report → signoff/*.txt"] @@ -28,7 +28,7 @@ Three design principles explain every line of the output: | Entry point | Artifact | Refresh | |---|---|---| -| Flow engine (automatic) | `/home/qor_report.json` (machine-readable, JSON Schema v3, see §10) | After every successful step (including skips of already-succeeded steps) | +| Flow engine (automatic) | `/home/qor_report.json` (machine-readable, schema_version 3, see §10) | After every successful step (including skips of already-succeeded steps) | | `ecc report qor` | `/signoff/_qor_report.txt` (human-readable text report) | Rebuilt from current artifacts on every invocation | | ECOS Studio | Project dashboard QoR card, 5-dimension breakdown, diagnosis list | Reads `home/qor_report.json`; missing or stale → NOT_RATED (§10.2) | @@ -111,7 +111,7 @@ PHYSICAL_FAIL (any gate failed) ≻ UNKNOWN (corrupt evidence) ≻ NOT_VERIFIED ## 3. How Each Dimension Is Computed -The default thresholds below are **calibrated engineering values** (CALIBRATED_HEURISTIC / USER_PROJECT_CONSTRAINT), not physical laws. They are centrally defined in [calibration.py](../analysis/qor/calibration.py) and are not user-facing parameters today. +The default thresholds below are **calibrated engineering values** (CALIBRATED_HEURISTIC / USER_PROJECT_CONSTRAINT), not physical laws, and are not user-facing parameters today. ### 3.1 Q_T — Timing Quality @@ -340,7 +340,7 @@ qor_power_budget_w = 0.5 | Source | Path | Purpose | |---|---|---| -| Per-step metrics | `/analysis/qor_metrics.json` (schema v3, emitted by each step's metrics.py — **unchanged**) | metric values and provenance | +| Per-step metrics | `/analysis/qor_metrics.json` (schema_version 3, emitted by every step — **unchanged**) | metric values and provenance | | Per-corner timing | `sta_ecc/feature//Cworst/qor_summary.json` | signed setup/hold WS, TNS, NVP; PVT dispersion | | Power | `sta_ecc/feature//Cworst/power_summary.json` (falls back to `Synthesis_yosys/feature/post_synthesis/power_summary.json`) | P_total | | Step states | `home/flow.json` | only steps whose state is `Success` are analyzed (stale artifacts after invalidation never score) | @@ -348,7 +348,7 @@ qor_power_budget_w = 0.5 When several steps emit the same metric id, selection prefers `project_role` (final > gate > trend) and, at equal priority, the later step wins. -### 9.2 Metric catalog consumed by the engine (authoritative copy in [metric_registry.py](../analysis/qor/metric_registry.py)) +### 9.2 Metric catalog consumed by the engine Synthesis: `synthesis_cell_area`, `synthesis_cell_count`, `synthesis_wire_count`, `synthesis_power_dynamic_uw`, `synthesis_power_leakage_uw`; Floorplan: `die_area`, `core_area`, `core_utilization`; @@ -359,7 +359,7 @@ RCX: `rcx_spef_file_count`, `rcx_expected/missing_corner_count`, `rcx_spef_parse STA: `sta_setup/hold_wns` (signed WS), `sta_setup/hold_tns`, `sta_setup/hold_violation_count`, `sta_frequency_mhz`, `sta_corner_count`, `sta_expected/missing_corner_count`, `sta_worst_setup_corner`; Signoff: `drc_count`, `lvs_count`, `harden_artifact_missing_count`. -### 9.3 Derived feature catalog (see [feature_registry.py](../analysis/qor/feature_registry.py)) +### 9.3 Derived feature catalog | Feature | Formula | Epistemic class | |---|---|---| @@ -440,7 +440,7 @@ Field quick reference: `feasibility` (gates), `evidence`, `qor_record` (five dim ## 11. Differences From the Legacy Scheme (Migration Notes) -| Aspect | Legacy (pre qor-v3) | Current (qor-v3) | +| Aspect | Legacy (pre-V3) | Current (V3) | |---|---|---| | Thresholds | absolute values (e.g., route_wirelength fail = 6000 µm), GCD-scale only, not comparable across designs | relative inflation (I = actual / geometric bound), comparable across designs | | Missing dimensions | no weight re-normalization; a missing power dimension capped the ceiling at 75 | re-normalized over evaluated dimensions; the ceiling is always 100 | @@ -448,7 +448,7 @@ Field quick reference: `feasibility` (gates), `evidence`, `qor_record` (five dim | Positive slack | any slack ≥ 0 scored 100 | continuous WS differentiation + over-constraint detection (OPPORTUNITY) | | Missing data | "not measured" and "measured zero" indistinguishable | trichotomy + null dimensions | | Timing weighting | WNS/TNS/frequency/NVP equally weighted (one fact counted 4×) | a single continuous Q_T; WNS/TNS/NVP for gating and diagnostics only | -| Implementation | a TS (GUI) and a Python (CLI) port of the same scorer; three threshold tables | one implementation in ECC; the GUI renders the report | +| Implementation | separate GUI and CLI scorers with three threshold tables; they could drift | one implementation in ECC; the GUI only renders the report | Migration impact (to know before upgrading): @@ -477,7 +477,7 @@ WS exceeds 0.20·T_clk — an over-constraint hint: the design may be over-buffe Both: `ws_ns` is the signed worst slack (used for continuous quality and headroom analysis); `wns_ns = min(0, ws_ns)` is the clamped negative slack (used for gating and violation magnitude). ECC's metric id `sta_setup_wns` historically borrows the wns abbreviation but carries the signed value. **Q: Can I change the thresholds (1.25/1.75, 0.45/0.70, …)?** -They are engine constants today (calibration.py), not user parameters. They are calibrated engineering defaults and may evolve between releases; route technology-specific calibration requests to the toolchain maintainers. +They are engine constants today, not user parameters. They are calibrated engineering defaults and may evolve between releases; route technology-specific calibration requests to the toolchain maintainers. **Q: Can `ecc report qor` and `home/qor_report.json` disagree?** Not normally: the report refreshes after every successful step and the CLI recomputes on the fly. If you hand-edit artifact files or run the flow concurrently, they may diverge briefly; after the flow finishes, a rerun of `ecc report qor` is authoritative.