Signal analysis pass (analyze), NC tests, DESIGN.md catch-up.

- New `src/system/analysis.{hpp,cpp}` — stateless post-processing pass
  `analyze_system(System*) → AnalysisReport`. Per-module detection of
  signal groups and anomalies; pure read, re-runnable.
  - Groups: diff pairs (`*_P` / `*_N`, case-insensitive), buses
    (`NAME[N]` or strict `NAME_N` — the `_` before digits is required
    so names like `GETH_01_VDD12` are not misread as a bus).
  - Anomalies: `DiffPairOrphan` (asymmetric: only `_P` without `_N` is
    reported — `_N` alone is overloaded with active-low semantics and
    floods the output with false positives), `BusGap` (missing index
    inside a detected `[lo..hi]`).
  - Noise filters: signals starting with `$` (Mentor internals) are
    skipped wholesale.
- New `analyze` shell command — prints groups sorted by module +
  label, then anomalies. Sized for the upcoming dashboard.
- `tests/test_analysis.cpp` — 8 cases covering both detectors, false-
  positive guards (no-underscore digits, `$`-prefixed internals), and
  per-module scoping.
- `tests/test_nc_origin.cpp` — completes the prior NC-tagging commit
  with round-trip + drop_singleton_signals coverage.
- DESIGN.md updated: layout entry for `analysis.{hpp,cpp}` and new
  section explaining the pass; NC-origin paragraph aligned with the
  actual tag semantics and the verify three-pass summary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 13:42:58 +02:00
parent 280526304d
commit 5e89b33088
6 changed files with 598 additions and 3 deletions

182
tests/test_analysis.cpp Normal file
View File

@@ -0,0 +1,182 @@
#include <doctest/doctest.h>
#include "system/analysis.hpp"
#include "system/modules.hpp"
#include "system/parts.hpp"
#include "system/pins.hpp"
#include "system/signals.hpp"
#include "system/system.hpp"
#include <memory>
#include <string>
namespace {
// Add a signal of the given name to a module, plus a single pin on a
// helper part so the signal is not stripped by `drop_singleton_signals`
// when callers exercise that path.
void add_signal(Module *mod, Part *prt, const std::string &sig_name) {
Pin *pin = new Pin("p_" + sig_name);
prt->add(pin);
Signal *s = mod->signals->merge(sig_name);
s->add(pin);
pin->connect(s);
}
} // namespace
TEST_CASE("analyze detects diff pairs and reports `_P` orphans only") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
add_signal(m, p, "GETH_DA_P");
add_signal(m, p, "GETH_DA_N");
add_signal(m, p, "PCIE_RX_P"); // orphan: _P only → reported
add_signal(m, p, "USB_DM"); // not a diff pair (no _P/_N)
add_signal(m, p, "lower_p");
add_signal(m, p, "lower_n"); // lowercase still matches
add_signal(m, p, "RESET_N"); // _N only → NOT reported (active-low)
add_signal(m, p, "BOOTMODE_N"); // _N only → NOT reported
AnalysisReport r = analyze_system(sys.get());
int dp = 0, orphans = 0;
for (const auto &g : r.groups)
if (g.kind == GroupKind::DiffPair) ++dp;
for (const auto &a : r.anomalies)
if (a.kind == AnomalyKind::DiffPairOrphan) ++orphans;
CHECK(dp == 2); // GETH_DA + lower
CHECK(orphans == 1); // only PCIE_RX_P
}
TEST_CASE("analyze detects buses with bracketed and underscore forms") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
add_signal(m, p, "DATA[0]");
add_signal(m, p, "DATA[1]");
add_signal(m, p, "DATA[2]");
add_signal(m, p, "ADDR_0");
add_signal(m, p, "ADDR_1");
add_signal(m, p, "STANDALONE");
AnalysisReport r = analyze_system(sys.get());
int buses = 0;
bool data_found = false, addr_found = false;
for (const auto &g : r.groups) {
if (g.kind != GroupKind::Bus) continue;
++buses;
if (g.label.find("DATA") == 0) {
data_found = true;
CHECK(g.lo == 0);
CHECK(g.hi == 2);
CHECK(g.members.size() == 3);
} else if (g.label.find("ADDR_") == 0) {
addr_found = true;
CHECK(g.lo == 0);
CHECK(g.hi == 1);
CHECK(g.members.size() == 2);
}
}
CHECK(buses == 2);
CHECK(data_found);
CHECK(addr_found);
}
TEST_CASE("analyze flags bus gaps") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
add_signal(m, p, "D[0]");
add_signal(m, p, "D[1]");
add_signal(m, p, "D[3]"); // gap at 2
add_signal(m, p, "D[5]"); // gap at 4
AnalysisReport r = analyze_system(sys.get());
int gaps = 0;
for (const auto &a : r.anomalies)
if (a.kind == AnomalyKind::BusGap) ++gaps;
CHECK(gaps == 1); // one bus, one anomaly listing all missing indices
}
TEST_CASE("analyze ignores single-suffix \"buses\" and pure numbers") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
add_signal(m, p, "CLK_100MHZ"); // not a bus (single signal)
add_signal(m, p, "GND"); // no digit suffix
AnalysisReport r = analyze_system(sys.get());
for (const auto &g : r.groups)
CHECK(g.kind != GroupKind::Bus);
}
TEST_CASE("analyze rejects digits not preceded by `_` (false-positive guard)") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
// GETH_01_VDD12, GETH_01_VDD13 look like a bus with stem "GETH_01_VDD"
// but there is no underscore before the digits — must NOT be detected.
add_signal(m, p, "GETH_01_VDD12");
add_signal(m, p, "GETH_01_VDD13");
add_signal(m, p, "GETH_01_VDD14");
AnalysisReport r = analyze_system(sys.get());
for (const auto &g : r.groups)
CHECK(g.kind != GroupKind::Bus);
}
TEST_CASE("analyze skips internal `$xxx` Mentor names") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
add_signal(m, p, "$N123");
add_signal(m, p, "$N124");
add_signal(m, p, "$N125");
add_signal(m, p, "$SIG_P");
add_signal(m, p, "$SIG_N");
AnalysisReport r = analyze_system(sys.get());
CHECK(r.groups.empty());
CHECK(r.anomalies.empty());
}
TEST_CASE("analyze on empty / null system") {
AnalysisReport r = analyze_system(nullptr);
CHECK(r.groups.empty());
CHECK(r.anomalies.empty());
auto sys = std::make_unique<System>();
r = analyze_system(sys.get());
CHECK(r.groups.empty());
CHECK(r.anomalies.empty());
}
TEST_CASE("analyze scopes detection per module (no cross-module merge)") {
auto sys = std::make_unique<System>();
Module *m1 = sys->modules()->merge("M1");
Module *m2 = sys->modules()->merge("M2");
Part *p1 = new Part("U1"); m1->add(p1);
Part *p2 = new Part("U1"); m2->add(p2);
add_signal(m1, p1, "SIG_P");
add_signal(m2, p2, "SIG_N"); // matching half lives in another module
AnalysisReport r = analyze_system(sys.get());
int dp = 0, orphans = 0;
for (const auto &g : r.groups)
if (g.kind == GroupKind::DiffPair) ++dp;
for (const auto &a : r.anomalies)
if (a.kind == AnomalyKind::DiffPairOrphan) ++orphans;
CHECK(dp == 0);
CHECK(orphans == 1); // only the `_P` side is reported (active-low rule)
}

125
tests/test_nc_origin.cpp Normal file
View File

@@ -0,0 +1,125 @@
#include <doctest/doctest.h>
#include "system/modules.hpp"
#include "system/parts.hpp"
#include "system/persist.hpp"
#include "system/pins.hpp"
#include "system/signals.hpp"
#include "system/system.hpp"
#include <filesystem>
#include <memory>
#include <string>
TEST_CASE("nc_origin_tag round-trips with from_tag for tagged variants") {
NcOrigin o;
REQUIRE(nc_origin_from_tag("U", o));
CHECK(o == NcOrigin::ImportedUnconnected);
REQUIRE(nc_origin_from_tag("D", o));
CHECK(o == NcOrigin::DroppedSingleton);
CHECK(std::string(nc_origin_tag(NcOrigin::ImportedUnconnected)) == "U");
CHECK(std::string(nc_origin_tag(NcOrigin::DroppedSingleton)) == "D");
CHECK(std::string(nc_origin_tag(NcOrigin::None)) == "");
}
TEST_CASE("nc_origin_from_tag rejects unknown / empty tags") {
NcOrigin o = NcOrigin::None;
CHECK(!nc_origin_from_tag("", o));
CHECK(!nc_origin_from_tag("X", o));
CHECK(!nc_origin_from_tag("Unknown", o));
}
TEST_CASE("drop_singleton_signals detaches size-1 signals and tags pins") {
Module mod("M");
Part *prt = new Part("U1");
mod.add(prt);
auto add_pin = [&](const std::string &pin_name, const std::string &sig) {
Pin *p = new Pin(pin_name);
prt->add(p);
Signal *s = mod.signals->merge(sig);
s->add(p);
p->connect(s);
return p;
};
// Two-pin signal: should survive.
Pin *pa = add_pin("A1", "BUS_X");
Pin *pb = add_pin("A2", "BUS_X");
// Singleton: should be dropped, pin tagged DroppedSingleton.
Pin *po = add_pin("A3", "ORPHAN");
REQUIRE(mod.signals->size() == 2);
int dropped = drop_singleton_signals(mod.signals);
CHECK(dropped == 1);
CHECK(mod.signals->size() == 1); // BUS_X kept
CHECK(mod.signals->exists("BUS_X"));
CHECK(!mod.signals->exists("ORPHAN"));
CHECK(pa->signal() != nullptr); // unchanged
CHECK(pb->signal() != nullptr);
CHECK(po->signal() == nullptr); // detached
CHECK(po->nc_origin == NcOrigin::DroppedSingleton);
CHECK(pa->nc_origin == NcOrigin::None);
}
TEST_CASE("drop_singleton_signals is a no-op on a clean module") {
Module mod("M");
Part *prt = new Part("U1");
mod.add(prt);
auto *p1 = new Pin("A1"); prt->add(p1);
auto *p2 = new Pin("A2"); prt->add(p2);
Signal *s = mod.signals->merge("MULTIPIN");
s->add(p1); p1->connect(s);
s->add(p2); p2->connect(s);
CHECK(drop_singleton_signals(mod.signals) == 0);
CHECK(mod.signals->size() == 1);
}
TEST_CASE("persist round-trip preserves nc_origin tags") {
auto sys = std::make_unique<System>();
Module *mod = sys->modules()->merge("M");
Part *prt = new Part("U1");
mod->add(prt);
auto *connected = new Pin("A1");
prt->add(connected);
Signal *s = mod->signals->merge("SIG");
s->add(connected); connected->connect(s);
auto *imported_nc = new Pin("A2");
imported_nc->nc_origin = NcOrigin::ImportedUnconnected;
prt->add(imported_nc);
auto *dropped = new Pin("A3");
dropped->nc_origin = NcOrigin::DroppedSingleton;
prt->add(dropped);
// A pin with no signal and no origin (e.g. an old snapshot or a
// FillIdentityNCs-style materialisation): tag stays None on restore.
auto *bare_nc = new Pin("A4");
prt->add(bare_nc);
std::string path =
(std::filesystem::temp_directory_path() / "essim_nc_origin.txt").string();
std::string err;
REQUIRE(save_system(sys.get(), path, err));
std::unique_ptr<System> restored(restore_system(path, err));
REQUIRE(restored);
std::filesystem::remove(path);
Part *rp = restored->modules()->get("M")->get("U1");
CHECK(rp->get("A1")->nc_origin == NcOrigin::None);
CHECK(rp->get("A2")->nc_origin == NcOrigin::ImportedUnconnected);
CHECK(rp->get("A3")->nc_origin == NcOrigin::DroppedSingleton);
CHECK(rp->get("A4")->nc_origin == NcOrigin::None);
}
TEST_CASE("next_signal_type cycles Power → Gnd → Other → Power") {
CHECK(next_signal_type(SignalType::Power) == SignalType::GndShield);
CHECK(next_signal_type(SignalType::GndShield) == SignalType::Other);
CHECK(next_signal_type(SignalType::Other) == SignalType::Power);
}