mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
* feat(#401): temporal provenance, OWL-Time export, stable snapshot schema - ProvenanceTracker: auto-attach recorded_at (UTC) to every new record; add query_recorded_between(), revision_history(), export_audit_log() - RDFExporter.export_to_rdf: add include_temporal + time_axis params; emit OWL-Time triples (time:Interval, time:Instant, inXSDDateTimeStamp) for relationships with valid_from/valid_until; TemporalBound.OPEN represented via semantica:openEndedInterval instead of time:hasEnd - TemporalVersionManager.create_snapshot: stamp format_version "1.0" on every snapshot; add validate_snapshot() and migrate_snapshot() - New: semantica/kg/schemas/temporal_snapshot_v1.json (JSON Schema draft-2020) - Tests: 28 new tests covering all acceptance criteria (451 passing, 0 failed) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(#401): add changelog entry for temporal provenance & export Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(#402): Temporal GraphRAG Integration — TemporalGraphRetriever & TemporalQueryRewriter - Add TemporalGraphRetriever to context_retriever.py (no new file per project convention) - Drop-in wrapper for ContextRetriever; filters related_entities/related_relationships via reconstruct_at_time(); at_time=None is a true passthrough - Returns new RetrievedContext objects (no in-place mutation) - Graceful ImportError if temporal modules unavailable - Add at_time + header_template to ContextRetriever._generate_reasoned_response() and query_with_reasoning() - Temporal header prepended to LLM context block only when at_time is set - Naive datetimes normalised to UTC before formatting - Header built with str.replace (not .format) to prevent format-string injection - Add TemporalQueryRewriter + TemporalQueryResult to semantica/kg/ - Regex-only (default) and LLM-assisted extraction modes - Resolves temporal phrases via TemporalNormalizer (deterministic, zero LLM) - Word-boundary guards on intent keywords; year fallback for noun-phrase dates - Never calls reconstruct_at_time — extraction only - Export TemporalGraphRetriever from semantica.context - Export TemporalQueryRewriter, TemporalQueryResult from semantica.kg - Add 99 tests across two new test files - tests/context/test_temporal_retriever.py (56 tests) - tests/kg/test_temporal_query_rewriter.py (43 tests) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(#402): add changelog entry for Temporal GraphRAG Integration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: rewrite and polish documentation site (#413) - Rewrote index.md to match README (tagline, badges, Problem/Solution text) - Improved getting-started, concepts, quickstart, installation, faq, use-cases, contributing, glossary, learning-more, examples, modules, architecture, cookbook, deep-dive pages: tighter prose, fixed headings/bullets, removed inconsistencies and duplicate sections - Removed overuse of emojis from headings in integration pages (docling, snowflake) - Fixed change_management reference page: closed unclosed JSON code block that broke the right TOC, demoted noisy sub-headings to bold text - CSS layout: widened content area (max-width 1440px grid, left sidebar 11rem, right TOC narrowed to 11rem for broader content), tightened TOC spacing and font size, fixed word-wrap/overflow on TOC links - Added mkdocs_local.yml for local serving without mkdocs-jupyter plugin Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(#318): SHACL Shape Generation & Validation (Phase 1 + Phase 2) Phase 1 — Generation: - Add SHACLGenerator, SHACLGraph, NodeShape, PropertyShape to ontology_generator.py - 6-stage pipeline: class index → node shapes → property shapes → inheritance propagation → quality tier → serialization - Three output formats: Turtle, JSON-LD, N-Triples - Three quality tiers: basic / standard (default) / strict (sh:closed) - 3-level+ inheritance propagation, cycle-safe, no duplicate shapes - No-domain properties attach to all node shapes - OntologyEngine.to_shacl(), export_shacl() added to engine.py - RDFExporter.export_shacl() added to rdf_exporter.py Phase 2 — Runtime Validation: - Add SHACLViolation, SHACLValidationReport, _run_pyshacl to ontology_validator.py - OntologyEngine.validate_graph() with shacl= or ontology= arguments - explain_violations(): rule-based plain-English explanations for all 7 SHACL constraint types - summary(), to_dict() on SHACLValidationReport for pipeline and LLM consumers - pyshacl/rdflib are optional deferred imports (pip install semantica[shacl]) Security & reliability fixes: - Replace path-heuristic (len/newline) with os.path.exists() in validate_graph - Add shacl_format parameter to validate_graph and _run_pyshacl; thread format through correctly - Fix validate_output format alias map in to_shacl (json-ld, n-triples aliases) - Deep-copy PropertyShape in _propagate_inheritance (dataclasses.replace) — no shared mutable refs - Deterministic Turtle prefix output via sorted(graph.prefixes.items()) - Use full rdf:type URI in sh:ignoredProperties — no prefix dependency Tests & docs: - Add TestSHACLGeneration (16 tests) to test_ontology_comprehensive.py - Add TestSHACLHierarchicalAndValidation (18 tests) to test_ontology_advanced.py - 34 new tests, 0 failures, 0 regressions across 1111-test suite - Update README: Unreleased section, Features, Modules table, Ontology code block, Installation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(#318): add CHANGELOG entry for SHACL Shape Generation & Validation Covers Phase 1 (generation), Phase 2 (runtime validation), all 5 security/reliability fixes, test results, and README updates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(#319): SKOS Vocabulary Module — namespace helpers, store helpers, OntologyEngine APIs, tests, docs Extends the existing ontology and triplet-store stack with first-class SKOS support without adding any new top-level packages. ### semantica/ontology/namespace_manager.py - `get_skos_uri(local_name)` — build full skos:core# URI from local name - `build_concept_scheme_uri(name)` — slug a human name into a stable ConceptScheme URI anchored at the configured base URI ### semantica/triplet_store/triplet_store.py - `add_skos_concept(concept_uri, scheme_uri, pref_label, ...)` — asserts ConceptScheme + Concept triples, prefLabel, altLabel, broader, narrower, related, definition, notation via existing `add_triplets()` API - `get_skos_concepts(scheme_uri=None)` — SPARQL SELECT via `execute_query()`, collapses multi-valued bindings into concept dicts ### semantica/ontology/engine.py - `list_vocabularies()` — list all skos:ConceptScheme instances - `list_concepts(scheme_uri)` — list concepts in a scheme with alt labels - `search_concepts(query, scheme_uri=None)` — case-insensitive substring search over prefLabel + altLabel; sanitises user input against SPARQL injection ### tests - `TestSKOSOntologyEngine` (14 tests) in test_ontology_comprehensive.py - `TestSKOSTripletStore` (6 tests) in test_triplet_store.py - All 1162 existing + new tests pass, 0 failures ### docs/reference/ontology.md - New "SKOS Vocabulary Management" section: data-model table, import examples (add_skos_concept + rdflib bulk), list/search API, NamespaceManager helpers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(#319): add CHANGELOG entry for SKOS Vocabulary Module Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add comprehensive test suites for Temporal Semantics (#395) and all Unreleased changelog features - tests/test_395_temporal_semantics_comprehensive.py — 113 tests covering #396–#399: bitemporal model, temporal consistency validation, query time-range aggregation, ContextGraph.state_at(), causal chain trace_at_time() - tests/test_unreleased_changelog_comprehensive.py — 92 tests covering all unreleased changelog gaps: AgentContext checkpoints (#399), audit trail / named tags / rollback protection (#394), snapshot schema compatibility (#393), ContextGraph pagination & min_weight & thread safety (#385), SKOS helpers (#319), SHACL quality tiers & export (#318), OllamaProvider base_url (#408), DatalogReasoner multi-hop & graph load (#371) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: Context Explainability Output Fixes — regression tests and centrality fix - Fixed CentralityCalculator._build_adjacency() to handle ContextGraph edges (ContextEdge dataclass objects with source_id/target_id) so degree centrality and related algorithms return correct results instead of empty dicts - Added 23 regression tests in tests/context/test_context_explainability_regression.py covering readable decision text preservation, enriched causal/path outputs, PolicyEngine consistent metadata across Cypher and fallback branches, EntityLinker similarity payloads, and KG consumer compatibility - Updated CHANGELOG.md [Unreleased] to reflect the bug fix and test additions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
565 lines
24 KiB
Python
565 lines
24 KiB
Python
"""
|
||
Regression tests for Context Explainability Output Fixes.
|
||
|
||
Covers:
|
||
- Readable decision text preservation in ContextGraph nodes and reconstruction paths
|
||
- Enriched causal/path outputs (from_scenario, to_scenario, scenario/outcome/category dicts)
|
||
- PolicyEngine.get_affected_decisions() consistent metadata across Cypher and fallback branches
|
||
- EntityLinker similarity flows return full enriched payloads
|
||
- KG consumer compatibility (node_embeddings, link_predictor, centrality_calculator, path_finder)
|
||
when ContextGraph is used as the graph store and get_neighbors returns enriched dicts
|
||
"""
|
||
|
||
import pytest
|
||
from datetime import datetime, timedelta
|
||
from unittest.mock import MagicMock, patch, PropertyMock
|
||
from typing import Any, Dict, List
|
||
|
||
from semantica.context.context_graph import ContextGraph
|
||
from semantica.context.decision_models import Decision
|
||
from semantica.context.entity_linker import EntityLinker
|
||
from semantica.context.policy_engine import PolicyEngine
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _make_decision(decision_id: str, scenario: str, reasoning: str,
|
||
category: str = "test", outcome: str = "approved",
|
||
confidence: float = 0.9, decision_maker: str = "agent_1") -> Decision:
|
||
return Decision(
|
||
decision_id=decision_id,
|
||
category=category,
|
||
scenario=scenario,
|
||
reasoning=reasoning,
|
||
outcome=outcome,
|
||
confidence=confidence,
|
||
timestamp=datetime.now(),
|
||
decision_maker=decision_maker,
|
||
)
|
||
|
||
|
||
# ===========================================================================
|
||
# Group 1 – Readable Decision Text Preservation
|
||
# ===========================================================================
|
||
|
||
class TestReadableDecisionTextPreservation:
|
||
"""Decision-node storage preserves full human-readable text, not IDs."""
|
||
|
||
def test_add_decision_scenario_stored_as_content(self):
|
||
"""scenario is stored as node.content, not as an opaque ID."""
|
||
g = ContextGraph()
|
||
d = _make_decision(
|
||
"d1",
|
||
scenario="Loan application for first-time buyer: $300k, FICO 720",
|
||
reasoning="Strong credit profile with stable income"
|
||
)
|
||
g.add_decision(d)
|
||
|
||
node = g.nodes["d1"]
|
||
assert node.content == d.scenario, (
|
||
"node.content must equal the full human-readable scenario string"
|
||
)
|
||
assert node.content != "d1", "node.content must NOT be the node ID"
|
||
|
||
def test_add_decision_reasoning_preserved_in_properties(self):
|
||
"""Full reasoning text is stored in node.properties, not truncated."""
|
||
g = ContextGraph()
|
||
long_reasoning = (
|
||
"Customer has 8-year payment history, zero delinquencies, debt-to-income "
|
||
"ratio of 28%, salary verified at $95k/year via W-2. Risk score: LOW."
|
||
)
|
||
d = _make_decision("d2", "Credit card limit review", long_reasoning)
|
||
g.add_decision(d)
|
||
|
||
node = g.nodes["d2"]
|
||
assert node.properties["reasoning"] == long_reasoning
|
||
assert len(node.properties["reasoning"]) > 50
|
||
|
||
def test_find_precedents_returns_decision_with_readable_scenario(self):
|
||
"""find_precedents() returns Decision objects whose .scenario is readable text."""
|
||
g = ContextGraph()
|
||
cause = _make_decision(
|
||
"cause_1",
|
||
scenario="Overdraft protection request – account in good standing 5 yrs",
|
||
reasoning="Long account history, low overdraft frequency"
|
||
)
|
||
effect = _make_decision(
|
||
"effect_1",
|
||
scenario="Fee waiver granted due to precedent overdraft approval",
|
||
reasoning="Follows precedent cause_1"
|
||
)
|
||
g.add_decision(cause)
|
||
g.add_decision(effect)
|
||
g.add_causal_relationship("cause_1", "effect_1", "PRECEDENT_FOR")
|
||
|
||
precedents = g.find_precedents("effect_1")
|
||
assert len(precedents) >= 1, "Should return at least one precedent"
|
||
|
||
p = precedents[0]
|
||
assert isinstance(p, Decision)
|
||
assert p.scenario, "Returned Decision.scenario must not be empty"
|
||
assert "overdraft" in p.scenario.lower() or "Overdraft" in p.scenario, (
|
||
f"scenario should contain human-readable text, got: {p.scenario!r}"
|
||
)
|
||
assert p.scenario != "cause_1", "scenario must NOT be the raw node ID"
|
||
|
||
def test_get_causal_chain_returns_readable_text(self):
|
||
"""get_causal_chain() returns Decision objects with scenario text from node.content."""
|
||
g = ContextGraph()
|
||
for did, scenario in [
|
||
("root", "Initial fraud alert triggered on account #7734"),
|
||
("mid", "Temporary hold placed pending fraud investigation"),
|
||
("leaf", "Card blocked; customer notified via SMS"),
|
||
]:
|
||
g.add_decision(_make_decision(did, scenario, f"reasoning for {did}"))
|
||
|
||
g.add_causal_relationship("root", "mid", "CAUSED")
|
||
g.add_causal_relationship("mid", "leaf", "CAUSED")
|
||
|
||
chain = g.get_causal_chain("leaf", direction="upstream")
|
||
assert len(chain) >= 1
|
||
|
||
for dec in chain:
|
||
assert isinstance(dec, Decision)
|
||
assert dec.scenario, "Each chained Decision must have non-empty scenario"
|
||
assert dec.scenario != dec.decision_id, (
|
||
f"scenario '{dec.scenario}' must not equal the decision_id"
|
||
)
|
||
|
||
|
||
# ===========================================================================
|
||
# Group 2 – Enriched Causal / Path Outputs
|
||
# ===========================================================================
|
||
|
||
class TestEnrichedCausalOutputs:
|
||
"""trace_decision_causality and analyze_decision_influence return readable dicts."""
|
||
|
||
def _graph_with_decisions(self):
|
||
g = ContextGraph()
|
||
alpha_id = g.record_decision(
|
||
category="mortgage",
|
||
scenario="Approve mortgage for tech employee earning $180k",
|
||
reasoning="Strong credit profile and stable income verified",
|
||
outcome="approved",
|
||
confidence=0.92,
|
||
entities=["tech_employee", "mortgage_dept"],
|
||
)
|
||
beta_id = g.record_decision(
|
||
category="auto_loan",
|
||
scenario="Approve auto-loan backed by employer letter",
|
||
reasoning="Employer verification provided, income above threshold",
|
||
outcome="approved",
|
||
confidence=0.85,
|
||
entities=["tech_employee", "auto_dept"],
|
||
)
|
||
return g, alpha_id, beta_id
|
||
|
||
def test_trace_decision_causality_hops_have_scenario_fields(self):
|
||
"""Each causal hop includes from_scenario and to_scenario with readable text."""
|
||
g, alpha_id, beta_id = self._graph_with_decisions()
|
||
chains = g.trace_decision_causality(beta_id, max_depth=3)
|
||
|
||
# At least one hop should exist (shared entity creates causal link)
|
||
if chains:
|
||
for hop_list in chains:
|
||
for hop in hop_list:
|
||
assert "from" in hop, "hop must have 'from' key"
|
||
assert "to" in hop, "hop must have 'to' key"
|
||
assert "from_scenario" in hop, (
|
||
f"hop must have 'from_scenario' key, got keys: {list(hop.keys())}"
|
||
)
|
||
assert "to_scenario" in hop, (
|
||
f"hop must have 'to_scenario' key, got keys: {list(hop.keys())}"
|
||
)
|
||
# Scenarios must be strings, not empty IDs
|
||
assert isinstance(hop["from_scenario"], str)
|
||
assert isinstance(hop["to_scenario"], str)
|
||
|
||
def test_analyze_decision_influence_direct_influence_is_enriched_dicts(self):
|
||
"""direct_influence list contains dicts with decision_id, scenario, outcome, category."""
|
||
g, alpha_id, beta_id = self._graph_with_decisions()
|
||
result = g.analyze_decision_influence(alpha_id)
|
||
|
||
assert "direct_influence" in result
|
||
assert isinstance(result["direct_influence"], list)
|
||
|
||
for item in result["direct_influence"]:
|
||
assert isinstance(item, dict), (
|
||
f"direct_influence items must be dicts, got {type(item)}"
|
||
)
|
||
for field in ("decision_id", "scenario", "outcome", "category"):
|
||
assert field in item, (
|
||
f"influence item missing field '{field}', keys: {list(item.keys())}"
|
||
)
|
||
|
||
def test_analyze_decision_influence_scores_contain_readable_fields(self):
|
||
"""influence_scores entries include scenario/outcome/category alongside score."""
|
||
g, alpha_id, beta_id = self._graph_with_decisions()
|
||
result = g.analyze_decision_influence(alpha_id)
|
||
|
||
assert "influence_scores" in result
|
||
for item in result["influence_scores"]:
|
||
assert "score" in item
|
||
assert "decision_id" in item
|
||
assert "scenario" in item
|
||
assert "category" in item
|
||
assert "outcome" in item
|
||
|
||
|
||
# ===========================================================================
|
||
# Group 3 – PolicyEngine Consistent Decision Metadata
|
||
# ===========================================================================
|
||
|
||
class TestPolicyEngineAffectedDecisions:
|
||
"""get_affected_decisions() returns enriched metadata from both branches."""
|
||
|
||
def _mock_store_with_query(self, records):
|
||
store = MagicMock()
|
||
store.execute_query.return_value = records
|
||
return store
|
||
|
||
def test_cypher_branch_returns_scenario_category_outcome_confidence(self):
|
||
"""Cypher results include scenario/category/outcome/confidence with actual values."""
|
||
records = [
|
||
{
|
||
"decision_id": "dec_abc",
|
||
"scenario": "Increase credit limit for platinum member",
|
||
"category": "credit",
|
||
"outcome": "approved",
|
||
"confidence": 0.88,
|
||
}
|
||
]
|
||
store = self._mock_store_with_query(records)
|
||
pe = PolicyEngine(graph_store=store)
|
||
|
||
affected = pe.get_affected_decisions("policy_1", "v1", "v2")
|
||
|
||
assert len(affected) == 1
|
||
d = affected[0]
|
||
assert d["scenario"] == "Increase credit limit for platinum member", (
|
||
f"scenario must be readable text, got: {d['scenario']!r}"
|
||
)
|
||
assert d["category"] == "credit"
|
||
assert d["outcome"] == "approved"
|
||
assert d["confidence"] == pytest.approx(0.88, abs=1e-6)
|
||
|
||
def test_fallback_branch_enriches_from_context_graph_nodes(self):
|
||
"""Fallback branch reads scenario/category/outcome/confidence from ContextGraph nodes."""
|
||
g = ContextGraph()
|
||
d = _make_decision(
|
||
"dec_xyz",
|
||
scenario="Block account after 3 failed PIN attempts",
|
||
reasoning="Security policy v1 requires lockout",
|
||
category="security",
|
||
outcome="blocked",
|
||
confidence=0.99,
|
||
)
|
||
g.add_decision(d)
|
||
# Add a policy node and the APPLIED_POLICY edge
|
||
g.add_node("policy_2:v1", "Policy", {"policy_id": "policy_2", "version": "v1"})
|
||
g.add_edge("dec_xyz", "policy_2:v1", "APPLIED_POLICY")
|
||
|
||
pe = PolicyEngine(graph_store=g)
|
||
|
||
affected = pe.get_affected_decisions("policy_2", "v1", "v2")
|
||
|
||
assert len(affected) == 1
|
||
d_out = affected[0]
|
||
assert d_out["decision_id"] == "dec_xyz"
|
||
# scenario must come from node.content, not be empty or the raw ID
|
||
assert d_out["scenario"], "scenario must not be empty"
|
||
assert d_out["scenario"] != "dec_xyz", (
|
||
f"scenario should be readable text not the node ID, got: {d_out['scenario']!r}"
|
||
)
|
||
assert "PIN" in d_out["scenario"] or "Block" in d_out["scenario"], (
|
||
f"scenario should reflect stored decision text, got: {d_out['scenario']!r}"
|
||
)
|
||
|
||
def test_both_branches_return_same_key_shape(self):
|
||
"""Both Cypher and fallback branches return dicts with identical required keys."""
|
||
required_keys = {"decision_id", "scenario", "category", "outcome", "confidence"}
|
||
|
||
# Cypher branch
|
||
store_cypher = self._mock_store_with_query([{
|
||
"decision_id": "d1",
|
||
"scenario": "some scenario",
|
||
"category": "cat",
|
||
"outcome": "out",
|
||
"confidence": 0.5,
|
||
}])
|
||
pe_c = PolicyEngine(graph_store=store_cypher)
|
||
cypher_result = pe_c.get_affected_decisions("p", "v1", "v2")
|
||
assert len(cypher_result) == 1
|
||
assert required_keys.issubset(cypher_result[0].keys()), (
|
||
f"Cypher branch missing keys: {required_keys - cypher_result[0].keys()}"
|
||
)
|
||
|
||
# Fallback branch
|
||
g = ContextGraph()
|
||
g.add_decision(_make_decision("d2", "fallback scenario", "fallback reason"))
|
||
g.add_node("p2:v1", "Policy", {})
|
||
g.add_edge("d2", "p2:v1", "APPLIED_POLICY")
|
||
pe_f = PolicyEngine(graph_store=g)
|
||
fallback_result = pe_f.get_affected_decisions("p2", "v1", "v2")
|
||
assert len(fallback_result) == 1
|
||
assert required_keys.issubset(fallback_result[0].keys()), (
|
||
f"Fallback branch missing keys: {required_keys - fallback_result[0].keys()}"
|
||
)
|
||
|
||
|
||
# ===========================================================================
|
||
# Group 4 – EntityLinker Similarity Payloads
|
||
# ===========================================================================
|
||
|
||
class TestEntityLinkerSimilarityPayloads:
|
||
"""EntityLinker similarity flows return enriched dicts, not bare IDs."""
|
||
|
||
def _linker(self):
|
||
return EntityLinker(
|
||
knowledge_graph={
|
||
"entities": [
|
||
{
|
||
"id": "ent_python",
|
||
"text": "Python programming language",
|
||
"type": "Technology",
|
||
},
|
||
{
|
||
"id": "ent_java",
|
||
"text": "Java programming language",
|
||
"type": "Technology",
|
||
},
|
||
{
|
||
"id": "ent_sql",
|
||
"text": "SQL database query language",
|
||
"type": "Language",
|
||
},
|
||
]
|
||
}
|
||
)
|
||
|
||
def test_find_similar_entities_returns_full_payload_keys(self):
|
||
"""find_similar_entities() returns dicts with entity_id, text, type, uri, similarity."""
|
||
linker = self._linker()
|
||
results = linker.find_similar_entities("Python language", threshold=0.1)
|
||
|
||
assert isinstance(results, list)
|
||
assert len(results) >= 1, "Should find at least one similar entity"
|
||
|
||
for item in results:
|
||
assert isinstance(item, dict)
|
||
for field in ("entity_id", "text", "type", "similarity"):
|
||
assert field in item, (
|
||
f"find_similar_entities result missing field '{field}', got: {list(item.keys())}"
|
||
)
|
||
# entity_id must be the stored ID, not empty
|
||
assert item["entity_id"], "entity_id must not be empty"
|
||
# similarity must be a non-negative float
|
||
assert isinstance(item["similarity"], (int, float))
|
||
assert item["similarity"] >= 0.0
|
||
|
||
def test_find_similar_entities_text_field_is_human_readable(self):
|
||
"""text field in similarity results is human-readable entity text, not an ID."""
|
||
linker = self._linker()
|
||
results = linker.find_similar_entities("Python language", threshold=0.1)
|
||
|
||
assert len(results) >= 1
|
||
for item in results:
|
||
assert item["text"] != item["entity_id"], (
|
||
f"text should be human-readable, not the entity ID: {item['text']!r}"
|
||
)
|
||
assert len(item["text"]) > 2
|
||
|
||
def test_find_similar_entities_sorted_by_similarity_descending(self):
|
||
"""Results are sorted by similarity in descending order."""
|
||
linker = self._linker()
|
||
results = linker.find_similar_entities("Python language", threshold=0.0)
|
||
|
||
if len(results) >= 2:
|
||
for i in range(len(results) - 1):
|
||
assert results[i]["similarity"] >= results[i + 1]["similarity"], (
|
||
"Results must be sorted by similarity descending"
|
||
)
|
||
|
||
def test_find_similar_public_alias_returns_full_payload(self):
|
||
"""find_similar() public alias delegates to find_similar_entities and returns full dicts."""
|
||
linker = self._linker()
|
||
results = linker.find_similar("Python language", threshold=0.1)
|
||
|
||
assert isinstance(results, list)
|
||
for item in results:
|
||
assert isinstance(item, dict)
|
||
assert "entity_id" in item
|
||
assert "text" in item
|
||
assert "similarity" in item
|
||
|
||
def test_find_similar_with_entity_dict_input(self):
|
||
"""find_similar() accepts an EntityDict as input and returns full dicts."""
|
||
linker = self._linker()
|
||
entity_dict = {"text": "Java language", "type": "Technology"}
|
||
results = linker.find_similar(entity_dict, threshold=0.1)
|
||
|
||
assert isinstance(results, list)
|
||
for item in results:
|
||
assert "entity_id" in item
|
||
assert "similarity" in item
|
||
|
||
def test_find_linked_entities_creates_entity_links_with_ids(self):
|
||
"""_find_linked_entities creates EntityLink objects with valid target entity IDs."""
|
||
linker = self._linker()
|
||
linker.assign_uri("ent_python", "Python programming language", "Technology")
|
||
|
||
links = linker._find_linked_entities(
|
||
entity_id="my_entity",
|
||
entity_text="Python language",
|
||
entity_type="Technology",
|
||
all_entities=[],
|
||
context=None,
|
||
)
|
||
|
||
assert isinstance(links, list)
|
||
for link in links:
|
||
# target_entity_id must be a stored entity ID, not empty or equal to text
|
||
assert link.target_entity_id, "target_entity_id must not be empty"
|
||
assert link.target_entity_id.startswith("ent_"), (
|
||
f"target_entity_id should be a stored entity ID, got: {link.target_entity_id!r}"
|
||
)
|
||
assert link.confidence >= 0.0
|
||
|
||
|
||
# ===========================================================================
|
||
# Group 5 – KG Consumer Compatibility
|
||
# ===========================================================================
|
||
|
||
class TestKGConsumerCompatibility:
|
||
"""KG algorithms normalize enriched neighbor/node dicts from ContextGraph correctly."""
|
||
|
||
def _graph_with_nodes(self, pairs):
|
||
"""Build a ContextGraph with given (id, label) pairs connected in a chain."""
|
||
g = ContextGraph()
|
||
for nid, label in pairs:
|
||
g.add_node(nid, label, {"name": nid})
|
||
# Connect in order
|
||
ids = [nid for nid, _ in pairs]
|
||
for i in range(len(ids) - 1):
|
||
g.add_edge(ids[i], ids[i + 1], "RELATED_TO")
|
||
return g
|
||
|
||
def test_node_embedder_build_adjacency_normalizes_enriched_dicts(self):
|
||
"""NodeEmbedder._build_adjacency strips enriched dicts to node IDs (no crash, no None)."""
|
||
from semantica.kg.node_embeddings import NodeEmbedder
|
||
|
||
g = self._graph_with_nodes([("A", "Person"), ("B", "Person"), ("C", "Person")])
|
||
embedder = NodeEmbedder()
|
||
|
||
# Verify get_neighbors on ContextGraph returns dicts (enriched)
|
||
raw = g.get_neighbors("A")
|
||
assert isinstance(raw[0], dict), "ContextGraph.get_neighbors should return dicts"
|
||
assert "id" in raw[0]
|
||
|
||
adjacency = embedder._build_adjacency(g, ["Person", "Person"], ["RELATED_TO"])
|
||
# Each node maps to a list of plain string IDs
|
||
for node_id, neighbors in adjacency.items():
|
||
assert isinstance(node_id, str)
|
||
for nb in neighbors:
|
||
assert isinstance(nb, str), (
|
||
f"adjacency neighbor must be a string ID, got {type(nb)}: {nb!r}"
|
||
)
|
||
assert nb is not None
|
||
|
||
def test_link_predictor_get_node_neighbors_normalizes_enriched_dicts(self):
|
||
"""LinkPredictor._get_node_neighbors strips enriched dicts to plain IDs."""
|
||
from semantica.kg.link_predictor import LinkPredictor
|
||
|
||
g = self._graph_with_nodes([("X", "Item"), ("Y", "Item"), ("Z", "Item")])
|
||
predictor = LinkPredictor()
|
||
|
||
neighbors = predictor._get_node_neighbors(g, "X")
|
||
assert isinstance(neighbors, list)
|
||
for nb in neighbors:
|
||
assert isinstance(nb, str), (
|
||
f"neighbor must be a plain string ID, got {type(nb)}: {nb!r}"
|
||
)
|
||
assert nb is not None
|
||
|
||
def test_link_predictor_score_link_works_with_context_graph(self):
|
||
"""score_link() runs without error when given a ContextGraph store."""
|
||
from semantica.kg.link_predictor import LinkPredictor
|
||
|
||
g = self._graph_with_nodes([
|
||
("n1", "Entity"), ("n2", "Entity"), ("n3", "Entity")
|
||
])
|
||
predictor = LinkPredictor()
|
||
|
||
score = predictor.score_link(g, "n1", "n3", method="common_neighbors")
|
||
assert isinstance(score, (int, float))
|
||
assert score >= 0.0
|
||
|
||
def test_centrality_calculator_get_filtered_neighbors_normalizes_dicts(self):
|
||
"""CentralityCalculator._get_filtered_neighbors strips enriched dicts to IDs."""
|
||
from semantica.kg.centrality_calculator import CentralityCalculator
|
||
|
||
g = self._graph_with_nodes([("c1", "Node"), ("c2", "Node"), ("c3", "Node")])
|
||
calc = CentralityCalculator()
|
||
|
||
neighbors = calc._get_filtered_neighbors(g, "c1", relationship_types=None)
|
||
assert isinstance(neighbors, list)
|
||
for nb in neighbors:
|
||
assert isinstance(nb, str), (
|
||
f"filtered neighbor must be a plain string ID, got {type(nb)}: {nb!r}"
|
||
)
|
||
|
||
def test_centrality_calculator_degree_centrality_works_with_context_graph(self):
|
||
"""calculate_degree_centrality() works with ContextGraph as the graph store."""
|
||
from semantica.kg.centrality_calculator import CentralityCalculator
|
||
|
||
g = self._graph_with_nodes([
|
||
("hub", "Node"), ("spoke1", "Node"), ("spoke2", "Node")
|
||
])
|
||
g.add_edge("hub", "spoke2", "RELATED_TO") # hub has extra edge
|
||
calc = CentralityCalculator()
|
||
|
||
result = calc.calculate_degree_centrality(g)
|
||
assert isinstance(result, dict)
|
||
# result has keys: centrality, rankings, max_degree, total_nodes
|
||
assert "centrality" in result
|
||
centrality = result["centrality"]
|
||
assert isinstance(centrality, dict)
|
||
assert len(centrality) > 0
|
||
for node_id, score in centrality.items():
|
||
assert isinstance(node_id, str)
|
||
assert isinstance(score, (int, float))
|
||
assert score >= 0.0
|
||
|
||
def test_path_finder_get_neighbors_normalizes_enriched_dicts(self):
|
||
"""PathFinder._get_neighbors strips enriched dicts to (id, edge_data) tuples."""
|
||
from semantica.kg.path_finder import PathFinder
|
||
|
||
g = self._graph_with_nodes([("p1", "Stop"), ("p2", "Stop"), ("p3", "Stop")])
|
||
finder = PathFinder()
|
||
|
||
neighbors = finder._get_neighbors(g, "p1")
|
||
assert isinstance(neighbors, list)
|
||
for item in neighbors:
|
||
node_id, edge_data = item
|
||
assert isinstance(node_id, str), (
|
||
f"neighbor node_id must be a plain string, got {type(node_id)}: {node_id!r}"
|
||
)
|
||
assert node_id is not None
|
||
|
||
def test_path_finder_dijkstra_works_with_context_graph(self):
|
||
"""dijkstra_shortest_path() runs without error on ContextGraph."""
|
||
from semantica.kg.path_finder import PathFinder
|
||
|
||
g = self._graph_with_nodes([
|
||
("start", "Node"), ("mid", "Node"), ("end", "Node")
|
||
])
|
||
finder = PathFinder()
|
||
|
||
result = finder.dijkstra_shortest_path(g, "start", "end")
|
||
assert result is not None
|
||
assert isinstance(result, list)
|
||
assert "start" in result
|
||
assert "end" in result
|