- src/types.hpp: complete UCWM type system in C++20 — 19 enums, 11 facet data types, all core structs (CanonicalObject, Constraint, Facet, GateSignal, WorldState, etc.) with full JSON round-trip serialization - src/main.cpp: smoke test — constructs apple-problem WorldState by hand, serializes to JSON - tests/test_types.cpp: 19 tests, 123 assertions, all passing - CMakeLists.txt: CMake + CPM build with nlohmann/json, spdlog, Catch2 - schemas/: JSON Schema contracts for all UCWM data types - gates/, specialists/, resolver/, synthesis/: language-agnostic interface contracts and domain specs for all pipeline layers - docs/: architecture, vocabulary, decision matrices, roadmap (6 phases, 28 sprints), sprint_001, implementation_constraints Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
437 lines
17 KiB
C++
437 lines
17 KiB
C++
#include <catch2/catch_test_macros.hpp>
|
|
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
|
#include "types.hpp"
|
|
|
|
using namespace ucwm;
|
|
using nlohmann::json;
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// CanonicalObject
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
TEST_CASE("CanonicalObject — construction and field access", "[types][object]") {
|
|
CanonicalObject obj;
|
|
obj.object_id = "E1";
|
|
obj.object_kind = ObjectKind::Entity;
|
|
obj.status = ObjectStatus::Active;
|
|
obj.confidence = 0.97;
|
|
obj.canonical_label = "Alice";
|
|
obj.aliases = {"she", "her"};
|
|
obj.surface_span = SurfaceSpan{"Alice", 0, 5};
|
|
obj.source_module = "entity_specialist";
|
|
|
|
REQUIRE(obj.object_id == "E1");
|
|
REQUIRE(obj.object_kind == ObjectKind::Entity);
|
|
REQUIRE(obj.status == ObjectStatus::Active);
|
|
REQUIRE_THAT(obj.confidence, Catch::Matchers::WithinAbs(0.97, 1e-9));
|
|
REQUIRE(obj.canonical_label.has_value());
|
|
REQUIRE(*obj.canonical_label == "Alice");
|
|
REQUIRE(obj.aliases.size() == 2);
|
|
REQUIRE(obj.surface_span.has_value());
|
|
REQUIRE(obj.surface_span->start == 0);
|
|
REQUIRE(obj.surface_span->end == 5);
|
|
REQUIRE_FALSE(obj.merged_into.has_value());
|
|
REQUIRE(obj.facet_refs.empty());
|
|
}
|
|
|
|
TEST_CASE("CanonicalObject — absent optionals stay absent", "[types][object]") {
|
|
CanonicalObject obj;
|
|
obj.object_id = "E2";
|
|
obj.object_kind = ObjectKind::Event;
|
|
obj.confidence = 0.5;
|
|
|
|
REQUIRE_FALSE(obj.surface_span.has_value());
|
|
REQUIRE_FALSE(obj.canonical_label.has_value());
|
|
REQUIRE_FALSE(obj.merged_into.has_value());
|
|
REQUIRE_FALSE(obj.split_from.has_value());
|
|
REQUIRE_FALSE(obj.source_module.has_value());
|
|
}
|
|
|
|
TEST_CASE("CanonicalObject — JSON round-trip", "[types][object][json]") {
|
|
CanonicalObject original;
|
|
original.object_id = "E1";
|
|
original.object_kind = ObjectKind::Entity;
|
|
original.status = ObjectStatus::Active;
|
|
original.confidence = 0.97;
|
|
original.canonical_label = "Alice";
|
|
original.aliases = {"she", "her"};
|
|
original.surface_span = SurfaceSpan{"Alice", 0, 5};
|
|
original.facet_refs = {"F1", "F2"};
|
|
original.source_module = "entity_specialist";
|
|
|
|
json j = original;
|
|
CanonicalObject restored = j.get<CanonicalObject>();
|
|
|
|
REQUIRE(restored.object_id == original.object_id);
|
|
REQUIRE(restored.object_kind == original.object_kind);
|
|
REQUIRE(restored.status == original.status);
|
|
REQUIRE_THAT(restored.confidence, Catch::Matchers::WithinAbs(original.confidence, 1e-9));
|
|
REQUIRE(restored.canonical_label == original.canonical_label);
|
|
REQUIRE(restored.aliases == original.aliases);
|
|
REQUIRE(restored.surface_span.has_value());
|
|
REQUIRE(restored.surface_span->text == "Alice");
|
|
REQUIRE(restored.facet_refs == original.facet_refs);
|
|
REQUIRE(restored.source_module == original.source_module);
|
|
}
|
|
|
|
TEST_CASE("CanonicalObject — absent optional not written to JSON", "[types][object][json]") {
|
|
CanonicalObject obj;
|
|
obj.object_id = "E3";
|
|
obj.object_kind = ObjectKind::Quantity;
|
|
obj.confidence = 0.99;
|
|
|
|
json j = obj;
|
|
REQUIRE_FALSE(j.contains("surface_span"));
|
|
REQUIRE_FALSE(j.contains("canonical_label"));
|
|
REQUIRE_FALSE(j.contains("merged_into"));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Constraint
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
TEST_CASE("Constraint — construction and field access", "[types][constraint]") {
|
|
Constraint c;
|
|
c.constraint_id = "C1";
|
|
c.constraint_type = "quantity_difference";
|
|
c.argument_refs = {"Q_result", "Q1", "Q2"};
|
|
c.polarity = Polarity::Positive;
|
|
c.strength = ConstraintStrength::Hard;
|
|
c.status = ConstraintStatus::Unresolved;
|
|
c.expression = "quantity(Q_result) = quantity(Q1) - quantity(Q2)";
|
|
c.confidence = 0.99;
|
|
c.source_module = "quantity_specialist";
|
|
|
|
REQUIRE(c.constraint_id == "C1");
|
|
REQUIRE(c.constraint_type == "quantity_difference");
|
|
REQUIRE(c.argument_refs.size() == 3);
|
|
REQUIRE(c.strength == ConstraintStrength::Hard);
|
|
REQUIRE(c.status == ConstraintStatus::Unresolved);
|
|
REQUIRE(c.expression.has_value());
|
|
REQUIRE(c.confidence.has_value());
|
|
REQUIRE_FALSE(c.probability.has_value());
|
|
}
|
|
|
|
TEST_CASE("Constraint — JSON round-trip", "[types][constraint][json]") {
|
|
Constraint original;
|
|
original.constraint_id = "C3";
|
|
original.constraint_type = "before";
|
|
original.argument_refs = {"EV1", "EV2"};
|
|
original.polarity = Polarity::Positive;
|
|
original.strength = ConstraintStrength::Soft;
|
|
original.status = ConstraintStatus::Resolved;
|
|
original.source_module = "temporal_specialist";
|
|
original.expression = "before(EV1, EV2)";
|
|
|
|
json j = original;
|
|
Constraint restored = j.get<Constraint>();
|
|
|
|
REQUIRE(restored.constraint_id == original.constraint_id);
|
|
REQUIRE(restored.constraint_type == original.constraint_type);
|
|
REQUIRE(restored.argument_refs == original.argument_refs);
|
|
REQUIRE(restored.polarity == original.polarity);
|
|
REQUIRE(restored.strength == original.strength);
|
|
REQUIRE(restored.status == original.status);
|
|
REQUIRE(restored.expression == original.expression);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Facets and FacetData variant
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
TEST_CASE("Facet — TemporalFacetData construction and kind detection", "[types][facet]") {
|
|
TemporalFacetData data;
|
|
data.time_point = "t0";
|
|
data.temporal_order_refs = {"C_temporal"};
|
|
data.is_recurring = false;
|
|
|
|
Facet f;
|
|
f.facet_id = "F_EV_temporal";
|
|
f.object_ref = "EV_initial";
|
|
f.confidence = 0.90;
|
|
f.source_module = "temporal_specialist";
|
|
f.data = data;
|
|
|
|
REQUIRE(kind_of(f.data) == FacetKind::Temporal);
|
|
REQUIRE(std::holds_alternative<TemporalFacetData>(f.data));
|
|
|
|
auto& d = std::get<TemporalFacetData>(f.data);
|
|
REQUIRE(d.time_point.has_value());
|
|
REQUIRE(*d.time_point == "t0");
|
|
REQUIRE(d.temporal_order_refs.size() == 1);
|
|
REQUIRE_FALSE(d.is_recurring);
|
|
}
|
|
|
|
TEST_CASE("Facet — QuantityFacetData with numeric value", "[types][facet]") {
|
|
QuantityFacetData data;
|
|
data.value = 5.0;
|
|
data.unit = "apples";
|
|
data.is_exact = true;
|
|
|
|
Facet f;
|
|
f.facet_id = "F_Q1";
|
|
f.object_ref = "Q1";
|
|
f.confidence = 0.99;
|
|
f.source_module = "quantity_specialist";
|
|
f.data = data;
|
|
|
|
REQUIRE(kind_of(f.data) == FacetKind::Quantity);
|
|
auto& d = std::get<QuantityFacetData>(f.data);
|
|
REQUIRE(d.value.has_value());
|
|
REQUIRE(std::holds_alternative<double>(*d.value));
|
|
REQUIRE_THAT(std::get<double>(*d.value), Catch::Matchers::WithinAbs(5.0, 1e-9));
|
|
REQUIRE(*d.unit == "apples");
|
|
REQUIRE(d.is_exact);
|
|
}
|
|
|
|
TEST_CASE("Facet — QuantityFacetData with symbolic value", "[types][facet]") {
|
|
QuantityFacetData data;
|
|
data.value = std::string{"Q1 - Q2"};
|
|
data.unit = "apples";
|
|
data.is_exact = false;
|
|
|
|
Facet f;
|
|
f.facet_id = "F_Q_final";
|
|
f.object_ref = "Q_alice_final";
|
|
f.confidence = 0.90;
|
|
f.source_module = "quantity_specialist";
|
|
f.data = data;
|
|
|
|
auto& d = std::get<QuantityFacetData>(f.data);
|
|
REQUIRE(std::holds_alternative<std::string>(*d.value));
|
|
REQUIRE(std::get<std::string>(*d.value) == "Q1 - Q2");
|
|
}
|
|
|
|
TEST_CASE("Facet — QuantityFacetData with null value", "[types][facet]") {
|
|
QuantityFacetData data;
|
|
data.unit = "apples";
|
|
data.is_exact = false;
|
|
// value intentionally left empty
|
|
|
|
Facet f;
|
|
f.facet_id = "F_Q_unresolved";
|
|
f.object_ref = "Q_alice_final";
|
|
f.confidence = 0.80;
|
|
f.source_module = "quantity_specialist";
|
|
f.data = data;
|
|
|
|
auto& d = std::get<QuantityFacetData>(f.data);
|
|
REQUIRE_FALSE(d.value.has_value());
|
|
}
|
|
|
|
TEST_CASE("Facet — JSON round-trip with TemporalFacetData", "[types][facet][json]") {
|
|
TemporalFacetData data;
|
|
data.time_point = "t1";
|
|
data.temporal_order_refs = {"C1", "C2"};
|
|
data.is_recurring = false;
|
|
|
|
Facet original;
|
|
original.facet_id = "F_temporal";
|
|
original.object_ref = "EV_transfer";
|
|
original.confidence = 0.88;
|
|
original.source_module = "temporal_specialist";
|
|
original.data = data;
|
|
|
|
json j = original;
|
|
|
|
REQUIRE(j["facet_kind"] == "temporal");
|
|
REQUIRE(j["facet_id"] == "F_temporal");
|
|
REQUIRE(j["time_point"] == "t1");
|
|
|
|
Facet restored = j.get<Facet>();
|
|
REQUIRE(restored.facet_id == original.facet_id);
|
|
REQUIRE(kind_of(restored.data) == FacetKind::Temporal);
|
|
|
|
auto& d = std::get<TemporalFacetData>(restored.data);
|
|
REQUIRE(*d.time_point == "t1");
|
|
REQUIRE(d.temporal_order_refs.size() == 2);
|
|
}
|
|
|
|
TEST_CASE("Facet — JSON round-trip with QuantityFacetData numeric value", "[types][facet][json]") {
|
|
QuantityFacetData data;
|
|
data.value = 3.0;
|
|
data.unit = "apples";
|
|
data.is_exact = true;
|
|
|
|
Facet original;
|
|
original.facet_id = "F_Q_resolved";
|
|
original.object_ref = "Q_alice_final";
|
|
original.confidence = 0.99;
|
|
original.source_module = "quantity_specialist";
|
|
original.data = data;
|
|
|
|
json j = original;
|
|
|
|
REQUIRE(j["facet_kind"] == "quantity");
|
|
REQUIRE(j["value"] == 3.0);
|
|
REQUIRE(j["unit"] == "apples");
|
|
REQUIRE(j["is_exact"] == true);
|
|
|
|
Facet restored = j.get<Facet>();
|
|
auto& d = std::get<QuantityFacetData>(restored.data);
|
|
REQUIRE(std::holds_alternative<double>(*d.value));
|
|
REQUIRE_THAT(std::get<double>(*d.value), Catch::Matchers::WithinAbs(3.0, 1e-9));
|
|
REQUIRE(*d.unit == "apples");
|
|
REQUIRE(d.is_exact);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// GateSignal
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
TEST_CASE("GateSignal — construction", "[types][gate]") {
|
|
GateSignal g;
|
|
g.gate_id = "has_quantity";
|
|
g.activated = true;
|
|
g.confidence = 0.99;
|
|
g.evidence_spans = {EvidenceSpan{"5 apples", 10, 18}};
|
|
g.method = GateMethod::Rule;
|
|
|
|
REQUIRE(g.gate_id == "has_quantity");
|
|
REQUIRE(g.activated);
|
|
REQUIRE_THAT(g.confidence, Catch::Matchers::WithinAbs(0.99, 1e-9));
|
|
REQUIRE(g.evidence_spans.size() == 1);
|
|
REQUIRE(g.evidence_spans[0].text == "5 apples");
|
|
REQUIRE(g.method.has_value());
|
|
REQUIRE(*g.method == GateMethod::Rule);
|
|
}
|
|
|
|
TEST_CASE("GateSignal — JSON round-trip", "[types][gate][json]") {
|
|
GateSignal original;
|
|
original.gate_id = "has_entity_reference";
|
|
original.activated = true;
|
|
original.confidence = 0.95;
|
|
original.method = GateMethod::Rule;
|
|
original.evidence_spans = {EvidenceSpan{"Alice", 0, 5}, EvidenceSpan{"Bob", 29, 32}};
|
|
|
|
json j = original;
|
|
GateSignal restored = j.get<GateSignal>();
|
|
|
|
REQUIRE(restored.gate_id == original.gate_id);
|
|
REQUIRE(restored.activated == original.activated);
|
|
REQUIRE_THAT(restored.confidence, Catch::Matchers::WithinAbs(original.confidence, 1e-9));
|
|
REQUIRE(restored.evidence_spans.size() == 2);
|
|
REQUIRE(restored.evidence_spans[0].text == "Alice");
|
|
REQUIRE(restored.method.has_value());
|
|
REQUIRE(*restored.method == GateMethod::Rule);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// WorldState
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
TEST_CASE("WorldState — construction with objects", "[types][worldstate]") {
|
|
WorldState ws;
|
|
ws.state_id = "ws_001";
|
|
ws.stage = WorldStateStage::PostProposal;
|
|
ws.input_text = "Alice had 5 apples.";
|
|
|
|
CanonicalObject alice;
|
|
alice.object_id = "E1";
|
|
alice.object_kind = ObjectKind::Entity;
|
|
alice.confidence = 0.97;
|
|
ws.objects["E1"] = alice;
|
|
|
|
CanonicalObject q1;
|
|
q1.object_id = "Q1";
|
|
q1.object_kind = ObjectKind::Quantity;
|
|
q1.confidence = 0.99;
|
|
ws.objects["Q1"] = q1;
|
|
|
|
REQUIRE(ws.objects.size() == 2);
|
|
REQUIRE(ws.objects.count("E1") == 1);
|
|
REQUIRE(ws.objects.at("E1").object_kind == ObjectKind::Entity);
|
|
REQUIRE(ws.facets.empty());
|
|
REQUIRE(ws.constraints.empty());
|
|
REQUIRE(ws.input_text.has_value());
|
|
REQUIRE(*ws.input_text == "Alice had 5 apples.");
|
|
REQUIRE_FALSE(ws.synthesis_answer.has_value());
|
|
}
|
|
|
|
TEST_CASE("WorldState — JSON round-trip", "[types][worldstate][json]") {
|
|
WorldState ws;
|
|
ws.state_id = "ws_roundtrip";
|
|
ws.stage = WorldStateStage::PostProposal;
|
|
ws.input_text = "Alice had 5 apples.";
|
|
|
|
CanonicalObject alice;
|
|
alice.object_id = "E1";
|
|
alice.object_kind = ObjectKind::Entity;
|
|
alice.status = ObjectStatus::Active;
|
|
alice.confidence = 0.97;
|
|
alice.canonical_label = "Alice";
|
|
alice.source_module = "entity_specialist";
|
|
ws.objects["E1"] = alice;
|
|
|
|
Facet fq;
|
|
fq.facet_id = "F_Q1";
|
|
fq.object_ref = "Q1";
|
|
fq.confidence = 0.99;
|
|
fq.source_module = "quantity_specialist";
|
|
QuantityFacetData qd;
|
|
qd.value = 5.0;
|
|
qd.unit = "apples";
|
|
qd.is_exact = true;
|
|
fq.data = qd;
|
|
ws.facets["F_Q1"] = fq;
|
|
|
|
json j = ws;
|
|
WorldState restored = j.get<WorldState>();
|
|
|
|
REQUIRE(restored.state_id == ws.state_id);
|
|
REQUIRE(restored.stage == ws.stage);
|
|
REQUIRE(restored.input_text == ws.input_text);
|
|
REQUIRE(restored.objects.size() == 1);
|
|
REQUIRE(restored.objects.count("E1") == 1);
|
|
REQUIRE(restored.objects.at("E1").canonical_label == alice.canonical_label);
|
|
REQUIRE(restored.facets.size() == 1);
|
|
REQUIRE(restored.facets.count("F_Q1") == 1);
|
|
REQUIRE(kind_of(restored.facets.at("F_Q1").data) == FacetKind::Quantity);
|
|
}
|
|
|
|
TEST_CASE("WorldState — stage enum JSON values", "[types][worldstate][json]") {
|
|
WorldState ws;
|
|
ws.state_id = "ws_stage_test";
|
|
|
|
ws.stage = WorldStateStage::PostGate;
|
|
REQUIRE(json(ws)["stage"] == "post_gate");
|
|
|
|
ws.stage = WorldStateStage::Resolved;
|
|
REQUIRE(json(ws)["stage"] == "resolved");
|
|
|
|
ws.stage = WorldStateStage::Synthesized;
|
|
REQUIRE(json(ws)["stage"] == "synthesized");
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Enum serialization spot checks
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
TEST_CASE("Enum JSON values match schema strings", "[types][enum][json]") {
|
|
REQUIRE(json(ObjectKind::Entity) == "entity");
|
|
REQUIRE(json(ObjectKind::RelationInstance) == "relation_instance");
|
|
REQUIRE(json(ConstraintStrength::Probabilistic) == "probabilistic");
|
|
REQUIRE(json(ConstraintStatus::Contradicted) == "contradicted");
|
|
REQUIRE(json(Polarity::Negative) == "negative");
|
|
REQUIRE(json(TruthStatus::True_) == "true");
|
|
REQUIRE(json(TruthStatus::False_) == "false");
|
|
REQUIRE(json(TransferType::Initial) == "initial");
|
|
REQUIRE(json(ExecutionMode::ConflictChecking) == "conflict_checking");
|
|
REQUIRE(json(ErrorKind::InsufficientData) == "insufficient_data");
|
|
}
|
|
|
|
TEST_CASE("SpecialistError — construction and JSON round-trip", "[types][error][json]") {
|
|
SpecialistError err;
|
|
err.error_kind = ErrorKind::Ambiguous;
|
|
err.affected_refs = {"E1", "E2"};
|
|
err.description = "Could not resolve coreference between E1 and E2";
|
|
|
|
json j = err;
|
|
REQUIRE(j["error_kind"] == "ambiguous");
|
|
|
|
SpecialistError restored = j.get<SpecialistError>();
|
|
REQUIRE(restored.error_kind == ErrorKind::Ambiguous);
|
|
REQUIRE(restored.affected_refs.size() == 2);
|
|
REQUIRE(restored.description == err.description);
|
|
}
|