diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dcd44cf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +.env +__pycache__ +*.pyc diff --git a/.env.example b/.env.example index e13b916..cd5bcdc 100644 --- a/.env.example +++ b/.env.example @@ -1,60 +1,60 @@ -# LLM -OPENAI_API_KEY=sk-... -OPENAI_MODEL=openai/gpt-4o-mini - -# Parser -# Options: nl2pln | canonical_pln | manhin | langextract -PARSER=canonical_pln -PLNRAG_PARSER=canonical_pln -NL2PLN_MODULE_PATH=data/simba_all.json -CANONICAL_PLN_NL2PLN_MODULE_PATH=data/simba_canonical_pln.json - -# LangExtract parser (GPT/OpenAI-backed) -LANGEXTRACT_API_KEY= -LANGEXTRACT_MODEL_ID=gpt-4o-mini -LANGEXTRACT_MODEL_URL= -LANGEXTRACT_EXAMPLES_PATH=data/langextract_examples.json -LANGEXTRACT_EXTRACTION_PASSES=1 -LANGEXTRACT_MAX_WORKERS=1 -LANGEXTRACT_SKIP_FUZZY=true - -# Vector store -QDRANT_URL=http://localhost:6333 -QDRANT_COLLECTION=pln_rag -OLLAMA_URL=http://localhost:11434/api/embeddings -OLLAMA_MODEL=nomic-embed-text - -# Atomspace -ATOMSPACE_PATH=data/atomspace/kb.metta - -# FAISS (used by Manhin parser) -FAISS_PATH=data/faiss - -# Processing -CHUNK_SIZE=512 -CHUNK_OVERLAP=64 -CONTEXT_TOP_K=10 -PARSER_BATCH_SENTENCES=4 -PARSER_BATCH_MAX_CHARS=2000 - -# Reasoning -CHAINING_TIMEOUT=180 -CHAINING_MAX_STEPS=100 - -# Query execution -QUERY_FALLBACK_ENABLED=true - -# ConceptNet background knowledge -CONCEPTNET_ENABLED=false -CONCEPTNET_AUTOLOAD=true -CONCEPTNET_INPUT_FILE=data/conceptnet/conceptnet-assertions-5.7.0.csv.gz -CONCEPTNET_ATOMSPACE_PATH=data/conceptnet/conceptnet_background.metta -CONCEPTNET_VECTOR_PAYLOAD_PATH=data/conceptnet/conceptnet_background.jsonl -CONCEPTNET_MANIFEST_PATH=data/conceptnet/conceptnet_manifest.json -CONCEPTNET_INDEX_ON_STARTUP=true -CONCEPTNET_MIN_WEIGHT=2.0 -CONCEPTNET_COVERAGE_PERCENT=100.0 -CONCEPTNET_SAMPLE_SEED=42 -CONCEPTNET_AUTO_REBUILD_ON_CHANGE=true -CONCEPTNET_REINDEX_ON_RESET=true -CONCEPTNET_STARTUP_FAIL_OPEN=true +# LLM +OPENAI_API_KEY=sk-... +OPENAI_MODEL=openai/gpt-4o-mini + +# Parser +# Options: nl2pln | canonical_pln | manhin | langextract +PARSER=canonical_pln +PLNRAG_PARSER=canonical_pln +NL2PLN_MODULE_PATH=data/simba_all.json +CANONICAL_PLN_NL2PLN_MODULE_PATH=data/simba_canonical_pln.json + +# LangExtract parser (GPT/OpenAI-backed) +LANGEXTRACT_API_KEY= +LANGEXTRACT_MODEL_ID=gpt-4o-mini +LANGEXTRACT_MODEL_URL= +LANGEXTRACT_EXAMPLES_PATH=data/langextract_examples.json +LANGEXTRACT_EXTRACTION_PASSES=1 +LANGEXTRACT_MAX_WORKERS=1 +LANGEXTRACT_SKIP_FUZZY=true + +# Vector store +QDRANT_URL=http://localhost:6333 +QDRANT_COLLECTION=pln_rag +OLLAMA_URL=http://localhost:11434/api/embeddings +OLLAMA_MODEL=nomic-embed-text + +# Atomspace +ATOMSPACE_PATH=data/atomspace/kb.metta + +# FAISS (used by Manhin parser) +FAISS_PATH=data/faiss + +# Processing +CHUNK_SIZE=512 +CHUNK_OVERLAP=64 +CONTEXT_TOP_K=10 +PARSER_BATCH_SENTENCES=4 +PARSER_BATCH_MAX_CHARS=2000 + +# Reasoning +CHAINING_TIMEOUT=180 +CHAINING_MAX_STEPS=100 + +# Query execution +QUERY_FALLBACK_ENABLED=true + +# ConceptNet background knowledge +CONCEPTNET_ENABLED=false +CONCEPTNET_AUTOLOAD=true +CONCEPTNET_INPUT_FILE=data/conceptnet/conceptnet-assertions-5.7.0.csv.gz +CONCEPTNET_ATOMSPACE_PATH=data/conceptnet/conceptnet_background.metta +CONCEPTNET_VECTOR_PAYLOAD_PATH=data/conceptnet/conceptnet_background.jsonl +CONCEPTNET_MANIFEST_PATH=data/conceptnet/conceptnet_manifest.json +CONCEPTNET_INDEX_ON_STARTUP=true +CONCEPTNET_MIN_WEIGHT=2.0 +CONCEPTNET_COVERAGE_PERCENT=100.0 +CONCEPTNET_SAMPLE_SEED=42 +CONCEPTNET_AUTO_REBUILD_ON_CHANGE=true +CONCEPTNET_REINDEX_ON_RESET=true +CONCEPTNET_STARTUP_FAIL_OPEN=true diff --git a/.gitignore b/.gitignore index f2448a9..d9881e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,16 @@ -.env -__pycache__/ -*.pyc -*.pyo -data/atomspace/ -data/faiss/ -*.metta -docs/reference/ -*matrix*.json -*_sanity.json -berekets_fallback_on_off.json -.venv -local-deps/ -data/benchmarks/* -!data/benchmarks/stress25_v1.json -data/conceptnet/conceptnet-assertions-5.7.0.csv.gz +.env +__pycache__/ +*.pyc +*.pyo +data/atomspace/ +data/faiss/ +*.metta +docs/reference/ +*matrix*.json +*_sanity.json +berekets_fallback_on_off.json +.venv +local-deps/ +data/benchmarks/* +!data/benchmarks/stress25_v1.json +data/conceptnet/conceptnet-assertions-5.7.0.csv.gz diff --git a/Dockerfile b/Dockerfile index 8de3f01..79a8183 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,8 +25,8 @@ FROM ubuntu:22.04 ENV DEBIAN_FRONTEND=noninteractive ENV PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 -ENV PETTA_COMMIT=e1490899cefc67c128d5311ff4861f9997674957 -ENV PETTACHAINER_COMMIT=d21b93b5132a7fc8722f64d57b74fb7c3a8d1faa +ENV PETTA_COMMIT=6b7f52f064bdbc82fabd0a0998404121fb01d52e +ENV PETTACHAINER_COMMIT=9f44164dd3252ccf8e9c63a4b84caec59f56080e RUN apt-get update && apt-get install -y \ software-properties-common \ @@ -65,6 +65,7 @@ WORKDIR /app COPY requirements.txt . RUN rm -rf /usr/lib/python3/dist-packages/blinker* RUN pip3 install --default-timeout=1000 -r requirements.txt +RUN python3 -m nltk.downloader wordnet omw-1.4 COPY . . diff --git a/api/main.py b/api/main.py index cbe84f2..9926927 100644 --- a/api/main.py +++ b/api/main.py @@ -98,5 +98,9 @@ async def health(): conceptnet_vectors_indexed=info["conceptnet_vectors_indexed"], conceptnet_vectors_expected=info["conceptnet_vectors_expected"], conceptnet_last_error=info["conceptnet_last_error"], + synonym_resolution_enabled=info["synonym_resolution_enabled"], + synonym_cached_pairs=info["synonym_cached_pairs"], + synonym_cached_synonyms=info["synonym_cached_synonyms"], + synonym_last_error=info["synonym_last_error"], uptime_seconds=round(time.time() - _start_time, 1), ) diff --git a/api/models.py b/api/models.py index 21dc1d6..0447a6d 100644 --- a/api/models.py +++ b/api/models.py @@ -83,4 +83,8 @@ class HealthResponse(BaseModel): conceptnet_vectors_indexed: int conceptnet_vectors_expected: int conceptnet_last_error: str + synonym_resolution_enabled: bool + synonym_cached_pairs: int + synonym_cached_synonyms: int + synonym_last_error: str uptime_seconds: float diff --git a/benchmark_parsers.py b/benchmark_parsers.py index 1aae8d9..83b7d9e 100644 --- a/benchmark_parsers.py +++ b/benchmark_parsers.py @@ -136,10 +136,11 @@ def _is_truthy(value: Any) -> bool: text = str(value).strip().lower() return text in {"1", "true", "yes", "y"} -ACTIVE_PARSERS = ("nl2pln", "canonical_pln") +ACTIVE_PARSERS = ("nl2pln", "canonical_pln", "canonical_senf_pln") AVAILABLE_PARSERS = ( "nl2pln", "canonical_pln", + "canonical_senf_pln", "langextract", "canonical_langextract", "canonical_pln_1686527", @@ -251,6 +252,10 @@ def _get_parser_factory(name: str): from parsers.canonical_pln_parser import CanonicalPLNParser return CanonicalPLNParser + if name == "canonical_senf_pln": + from parsers.canonical_senf_pln_parser import CanonicalSenfPlnParser + + return CanonicalSenfPlnParser if name == "langextract": from parsers.langextract_pln_parser import LangExtractPLNParser diff --git a/compare_parsers.py b/compare_parsers.py index 24b242d..ff5b2d6 100644 --- a/compare_parsers.py +++ b/compare_parsers.py @@ -7,11 +7,13 @@ def _load_parser_factories() -> dict[str, Callable[[], object]]: from parsers.canonical_pln_parser import CanonicalPLNParser + from parsers.canonical_senf_pln_parser import CanonicalSenfPlnParser from parsers.nl2pln_parser import NL2PLNParser factories: dict[str, Callable[[], object]] = { "nl2pln": NL2PLNParser, "canonical_pln": CanonicalPLNParser, + "canonical_senf_pln": CanonicalSenfPlnParser, } try: diff --git a/config.py b/config.py index 23e2335..1c60d2e 100644 --- a/config.py +++ b/config.py @@ -9,7 +9,7 @@ class Settings(BaseSettings): openai_api_key: str openai_model: str = "openai/gpt-4o-mini" - # Options: "nl2pln" | "canonical_pln" | "manhin" | "langextract" + # Options: "nl2pln" | "canonical_pln" | "manhin" | "langextract" | "canonical_senf_pln" parser: str = "canonical_pln" nl2pln_module_path: str = "data/simba_all.json" canonical_pln_nl2pln_module_path: str = "data/simba_canonical_pln.json" @@ -50,6 +50,21 @@ class Settings(BaseSettings): # Query execution query_fallback_enabled: bool = True + synonym_resolution_enabled: bool = True + synonym_cache_path: str = "data/synonyms/relations.json" + synonym_wordnet_enabled: bool = True + synonym_conceptnet_lookup_enabled: bool = True + synonym_conceptnet_url: str = "https://api.conceptnet.io" + synonym_conceptnet_limit: int = 50 + synonym_embedding_enabled: bool = True + synonym_embedding_threshold: float = 0.68 + synonym_embedding_top_k: int = 3 + synonym_max_knowledge_terms: int = 64 + synonym_max_verifications_per_query: int = 6 + synonym_verifier_model: Optional[str] = None + synonym_verifier_min_confidence: float = 0.85 + synonym_request_timeout: float = 10.0 + # Maximum number of query candidates to try before giving up. # Applies to all parsers when query_fallback_enabled is true. # Set to 0 to disable the cap. diff --git a/core/exemplar_registry.py b/core/exemplar_registry.py new file mode 100644 index 0000000..90d74bb --- /dev/null +++ b/core/exemplar_registry.py @@ -0,0 +1,59 @@ +from typing import List, Dict, Tuple +from core.senf import SENF, SENFExemplar, SENFEntity + +class ExemplarScorer: + """ + For each entity-kind pair, score distances to a small exemplar set. + """ + + # Small exemplar registry for common and domain-relevant kinds: + REGISTRY: Dict[str, List[str]] = { + "Camera": ["professional_camera", "consumer_camera", "phone_camera", "security_camera"], + "Game": ["chess_game", "football_game", "childrens_game", "video_game"], + "Bird": ["robin", "eagle", "penguin", "ostrich"], + "Treatment": ["drug_treatment", "surgical_treatment", "behavioral_treatment"] + } + + # Simple lexical cues for the MVP + CUES: Dict[str, Dict[str, float]] = { + "nikon": {"professional_camera": 0.12, "consumer_camera": 0.48}, + "lens": {"professional_camera": 0.2, "consumer_camera": 0.5}, + "strategy": {"chess_game": 0.05, "football_game": 0.80}, + "exhausting": {"football_game": 0.06, "chess_game": 0.82} + } + + def score(self, senf: SENF, context_text: str = "") -> None: + """ + Populate exemplars in the SENF object based on entities and context. + """ + context_lower = context_text.lower() + + for ent_id, entity in senf.entities.items(): + if not entity.kind: + continue + + # If we don't have exemplars for this kind, skip + if entity.kind not in self.REGISTRY: + continue + + prototypes = self.REGISTRY[entity.kind] + + # Simple heuristic distance calculation for the MVP + for proto in prototypes: + distance = 0.5 # default moderate distance + + # Check lexical cues in the context + for cue, distances in self.CUES.items(): + if cue in context_lower: + if proto in distances: + distance = distances[proto] + break + + senf.exemplars.append( + SENFExemplar( + entity_id=ent_id, + kind=entity.kind, + prototype=proto, + distance=distance + ) + ) diff --git a/core/horn_fallback.py b/core/horn_fallback.py new file mode 100644 index 0000000..af050db --- /dev/null +++ b/core/horn_fallback.py @@ -0,0 +1,245 @@ +import re +from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple + +from core.symbol_normalization import canonical_symbol, equivalent_symbol + + +Expression = Any +Bindings = Dict[str, Expression] + + +def parse_expression(text: str) -> Optional[Expression]: + tokens = re.findall(r"\(|\)|[^\s()]+", text) + if not tokens: + return None + + def parse_at(index: int) -> Tuple[Expression, int]: + if index >= len(tokens): + raise ValueError("Unexpected end of expression") + token = tokens[index] + if token != "(": + return token, index + 1 + + result: List[Expression] = [] + index += 1 + while index < len(tokens) and tokens[index] != ")": + value, index = parse_at(index) + result.append(value) + if index >= len(tokens): + raise ValueError("Unclosed expression") + return result, index + 1 + + try: + expression, next_index = parse_at(0) + except ValueError: + return None + return expression if next_index == len(tokens) else None + + +class HornFallback: + def __init__( + self, + statements: List[str], + max_steps: int = 100, + additional_equivalences: Iterable[Tuple[str, str]] | None = None, + ): + self.max_steps = max(1, max_steps) + self._additional_equivalences = { + frozenset((canonical_symbol(left), canonical_symbol(right))) + for left, right in (additional_equivalences or []) + } + self.facts: List[Tuple[Expression, str]] = [] + self.rules: List[Tuple[List[Expression], List[Expression], str]] = [] + self._load(statements) + + def prove(self, query_atom: str) -> List[str]: + goal = parse_expression(query_atom) + if not isinstance(goal, list) or not goal: + return [] + result = self._prove(goal, {}, 0, set()) + if not result: + return [] + _, proof = result + return self._dedupe(proof) + + def _load(self, statements: List[str]) -> None: + for raw in statements: + expression = parse_expression(raw) + if ( + not isinstance(expression, list) + or len(expression) < 4 + or expression[0] != ":" + ): + continue + body = expression[2] + if not isinstance(body, list) or not body: + continue + if body[0] != "Implication": + self.facts.append((body, raw)) + continue + premises = self._section(body, "Premises") + conclusions = self._section(body, "Conclusions") + if conclusions: + self.rules.append((premises, conclusions, raw)) + + def _section(self, implication: List[Expression], name: str) -> List[Expression]: + for item in implication[1:]: + if isinstance(item, list) and item and item[0] == name: + return self._flatten_conjunctions(item[1:]) + return [] + + def _flatten_conjunctions(self, expressions: List[Expression]) -> List[Expression]: + flattened: List[Expression] = [] + for expression in expressions: + if isinstance(expression, list) and expression and expression[0] == "And": + flattened.extend(self._flatten_conjunctions(expression[1:])) + else: + flattened.append(expression) + return flattened + + def _prove( + self, + goal: Expression, + bindings: Bindings, + steps: int, + trail: set[str], + ) -> Optional[Tuple[Bindings, List[str]]]: + return next( + self._prove_candidates(goal, bindings, steps, trail), + None, + ) + + def _prove_candidates( + self, + goal: Expression, + bindings: Bindings, + steps: int, + trail: set[str], + ) -> Iterator[Tuple[Bindings, List[str]]]: + if steps >= self.max_steps: + return + + grounded_goal = self._substitute(goal, bindings) + key = self._render(grounded_goal) + if key in trail: + return + next_trail = set(trail) + next_trail.add(key) + + for fact, raw in self.facts: + matched = self._unify(grounded_goal, fact, dict(bindings)) + if matched is not None: + yield matched, [raw] + + for index, (premises, conclusions, raw) in enumerate(self.rules): + suffix = f"__{steps}_{index}" + fresh_premises = [self._freshen(item, suffix) for item in premises] + for conclusion in conclusions: + fresh_conclusion = self._freshen(conclusion, suffix) + matched = self._unify( + fresh_conclusion, + grounded_goal, + dict(bindings), + ) + if matched is None: + continue + for result_bindings, proof in self._prove_all_candidates( + fresh_premises, + matched, + steps + 1, + next_trail, + ): + yield result_bindings, proof + [raw] + + def _prove_all_candidates( + self, + goals: List[Expression], + bindings: Bindings, + steps: int, + trail: set[str], + ) -> Iterator[Tuple[Bindings, List[str]]]: + if not goals: + yield bindings, [] + return + + first = self._substitute(goals[0], bindings) + for next_bindings, first_proof in self._prove_candidates( + first, + bindings, + steps, + trail, + ): + for final_bindings, rest_proof in self._prove_all_candidates( + goals[1:], + next_bindings, + steps + 1, + trail, + ): + yield final_bindings, first_proof + rest_proof + + def _unify( + self, + left: Expression, + right: Expression, + bindings: Bindings, + ) -> Optional[Bindings]: + left = self._resolve(left, bindings) + right = self._resolve(right, bindings) + + if self._is_variable(left): + bindings[left] = right + return bindings + if self._is_variable(right): + bindings[right] = left + return bindings + if isinstance(left, list) and isinstance(right, list): + if len(left) != len(right): + return None + for left_item, right_item in zip(left, right): + bindings = self._unify(left_item, right_item, bindings) + if bindings is None: + return None + return bindings + if isinstance(left, str) and isinstance(right, str): + if equivalent_symbol(left, right): + return bindings + pair = frozenset((canonical_symbol(left), canonical_symbol(right))) + return bindings if pair in self._additional_equivalences else None + return bindings if left == right else None + + def _resolve(self, value: Expression, bindings: Bindings) -> Expression: + seen: set[str] = set() + while self._is_variable(value) and value in bindings and value not in seen: + seen.add(value) + value = bindings[value] + return value + + def _substitute(self, value: Expression, bindings: Bindings) -> Expression: + value = self._resolve(value, bindings) + if isinstance(value, list): + return [self._substitute(item, bindings) for item in value] + return value + + def _freshen(self, value: Expression, suffix: str) -> Expression: + if self._is_variable(value): + return f"{value}{suffix}" + if isinstance(value, list): + return [self._freshen(item, suffix) for item in value] + return value + + def _is_variable(self, value: Expression) -> bool: + return isinstance(value, str) and value.startswith(("$", "?")) + + def _render(self, value: Expression) -> str: + if isinstance(value, list): + return f"({' '.join(self._render(item) for item in value)})" + return str(value) + + def _dedupe(self, statements: List[str]) -> List[str]: + seen: set[str] = set() + result: List[str] = [] + for statement in statements: + if statement not in seen: + seen.add(statement) + result.append(statement) + return result diff --git a/core/identity_graph.py b/core/identity_graph.py new file mode 100644 index 0000000..0e0ea4b --- /dev/null +++ b/core/identity_graph.py @@ -0,0 +1,60 @@ +from core.senf import SENF, IdentityEdgePlus, IdentityEdgeMinus + +class IdentityGraphBuilder: + + def build_graph(self, senf: SENF) -> None: + entities = list(senf.entities.values()) + + # O(N^2) comparison for local window + for i in range(len(entities)): + for j in range(i + 1, len(entities)): + ent1 = entities[i] + ent2 = entities[j] + + # Rule-based scoring + self._evaluate_pair(ent1, ent2, senf) + + def _evaluate_pair(self, ent1, ent2, senf: SENF) -> None: + plus_reasons = [] + minus_reasons = [] + plus_cost = 0.0 + minus_cost = 0.0 + + # 1. Kind compatibility + if ent1.kind and ent2.kind: + if ent1.kind == ent2.kind: + plus_reasons.append("same-kind") + plus_cost += 0.2 + else: + minus_reasons.append("incompatible-kind") + minus_cost += 0.8 + + # 2. Exemplar matching + # Find exemplars for these entities if they exist + ex1 = self._get_best_exemplar(ent1.id, senf) + ex2 = self._get_best_exemplar(ent2.id, senf) + + if ex1 and ex2: + if ex1.prototype == ex2.prototype: + plus_reasons.append("exemplar-match") + plus_cost += 0.1 + else: + minus_reasons.append("exemplar-mismatch") + minus_cost += 0.6 + + # Only emit edges if we have substantial evidence + if plus_reasons: + senf.id_plus_edges.append( + IdentityEdgePlus(ent1.id, ent2.id, plus_cost, plus_reasons) + ) + + if minus_reasons: + senf.id_minus_edges.append( + IdentityEdgeMinus(ent1.id, ent2.id, minus_cost, minus_reasons) + ) + + def _get_best_exemplar(self, entity_id: str, senf: SENF): + exs = [ex for ex in senf.exemplars if ex.entity_id == entity_id] + if not exs: + return None + return min(exs, key=lambda x: x.distance) diff --git a/core/pln_bridge.py b/core/pln_bridge.py new file mode 100644 index 0000000..c807208 --- /dev/null +++ b/core/pln_bridge.py @@ -0,0 +1,26 @@ +from typing import List +from core.transweave import Weave + +class PLNBridgeGenerator: + + def generate_bridges(self, weave: Weave) -> List[str]: + """ + Takes a Weave object and emits PLN atoms. + """ + atoms = [] + + # Calculate confidence based on the linear decay proposed in the MVP plan + # Confidence = max(0.01, 1.0 - (Cost * 0.5)) + confidence = max(0.01, 1.0 - (weave.cost * 0.5)) + + # Format the truth value string + tv_str = f"(STV {confidence:.2f} 0.90)" # Using 0.90 as a fixed weight/count for now + + # Generate SimilarityLinks for mapped entities + for i, (e_a, e_b) in enumerate(weave.entity_map.items()): + atoms.append(f"(: sim_link_{weave.id}_{i} (SimilarityLink {e_a} {e_b}) {tv_str})") + + # Optional: Emit the ContextLink encapsulation + atoms.append(f"(: ctx_link_{weave.id} (ContextLink {weave.sa_id} {weave.sb_id}) {tv_str})") + + return atoms diff --git a/core/reasoner.py b/core/reasoner.py index 5c08841..8ef8431 100644 --- a/core/reasoner.py +++ b/core/reasoner.py @@ -3,7 +3,9 @@ import threading from typing import List from config import get_settings +from core.horn_fallback import HornFallback from core.symbol_normalization import canonical_symbol +from core.synonym_resolver import SynonymResolver from pettachainer.pettachainer import PeTTaChainer @@ -24,9 +26,14 @@ def __init__(self): cfg = get_settings() self._atomspace_path = cfg.atomspace_path self._query_timeout = cfg.chaining_timeout + self._query_max_steps = cfg.chaining_max_steps self._lock = threading.Lock() self._handler = PeTTaChainer() + self._synonym_resolver = SynonymResolver(cfg) self._background_files: set[str] = set() + + self.load_background_file(os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "transweave_rules.metta")) + self._load_from_disk() def _load_from_disk(self): @@ -92,7 +99,7 @@ def add_statements_report(self, statements: List[str]) -> tuple[List[str], List[ rejected.append({"stmt": clean, "error": err}) return added, rejected - def query(self, pln_query: str) -> List[str]: + def query(self, pln_query: str, context: str = "") -> List[str]: """ Run a PLN query and return proof traces. Try exact fact lookup first for grounded queries, then fall back to @@ -101,6 +108,9 @@ def query(self, pln_query: str) -> List[str]: exact = self._query_exact_fact(pln_query) if exact: return exact + fallback = self._query_horn_fallback(pln_query, context) + if fallback: + return fallback try: result = self._handler.query(pln_query, timeout_sec=self._query_timeout) return result if result else [] @@ -108,6 +118,29 @@ def query(self, pln_query: str) -> List[str]: print(f"[Reasoner] Query failed for '{pln_query}': {e}") return [] + def _query_horn_fallback(self, pln_query: str, context: str = "") -> List[str]: + target = self._extract_grounded_query_atom(pln_query) + if not target: + return [] + statements: List[str] = [] + for path in self._fact_sources(): + with open(path, "r", encoding="utf-8") as handle: + statements.extend( + line.strip() + for line in handle + if line.strip() and not line.lstrip().startswith(";") + ) + equivalences = self._synonym_resolver.discover_equivalences( + target, + statements, + context, + ) + return HornFallback( + statements, + max_steps=self._query_max_steps, + additional_equivalences=equivalences, + ).prove(target) + def _query_exact_fact(self, pln_query: str) -> List[str]: target = self._extract_grounded_query_atom(pln_query) if not target: @@ -215,3 +248,7 @@ def background_size(self) -> int: with open(path, encoding="utf-8") as handle: total += sum(1 for line in handle if line.strip()) return total + + @property + def synonym_status(self) -> dict: + return self._synonym_resolver.status() diff --git a/core/senf.py b/core/senf.py new file mode 100644 index 0000000..2f44165 --- /dev/null +++ b/core/senf.py @@ -0,0 +1,111 @@ +from dataclasses import dataclass, field +from typing import List, Dict, Any, Optional +import re + +@dataclass +class SENFEntity: + id: str + kind: str = "" + properties: List[str] = field(default_factory=list) + +@dataclass +class SENFFrame: + id: str + head: str + roles: Dict[str, str] = field(default_factory=dict) # role_name -> entity_id + +@dataclass +class SENFExemplar: + entity_id: str + kind: str + prototype: str + distance: float + +@dataclass +class IdentityEdge: + e1: str + e2: str + cost: float + reasons: List[str] + +@dataclass +class IdentityEdgePlus(IdentityEdge): + pass + +@dataclass +class IdentityEdgeMinus(IdentityEdge): + pass + +@dataclass +class SENF: + frames: List[SENFFrame] = field(default_factory=list) + entities: Dict[str, SENFEntity] = field(default_factory=dict) + exemplars: List[SENFExemplar] = field(default_factory=list) + id_plus_edges: List[IdentityEdgePlus] = field(default_factory=list) + id_minus_edges: List[IdentityEdgeMinus] = field(default_factory=list) + raw_atoms: List[str] = field(default_factory=list) + + def to_metta_strings(self) -> List[str]: + """Serialize SENF data back to MeTTa atoms.""" + atoms = list(self.raw_atoms) # Keep the original canonical atoms + + # Add exemplar data + for ex in self.exemplars: + atoms.append(f"(exemplar {ex.entity_id} {ex.kind} {ex.prototype} {ex.distance:.2f})") + + # Add nearest-ex (assuming we pick the minimum distance for now) + if self.exemplars: + # Group by entity + entity_exs = {} + for ex in self.exemplars: + if ex.entity_id not in entity_exs: + entity_exs[ex.entity_id] = [] + entity_exs[ex.entity_id].append(ex) + + for entity_id, exs in entity_exs.items(): + best_ex = min(exs, key=lambda x: x.distance) + atoms.append(f"(nearest-ex {entity_id} {best_ex.kind} {best_ex.prototype})") + + # Add Identity Edges + for i, edge in enumerate(self.id_plus_edges): + reasons_str = " ".join(edge.reasons) + atoms.append(f"(: id_plus_{i} (IdPlus {edge.e1} {edge.e2} {edge.cost:.2f} (reasons {reasons_str})) (STV 1.0 1.0))") + + for i, edge in enumerate(self.id_minus_edges): + reasons_str = " ".join(edge.reasons) + atoms.append(f"(: id_minus_{i} (IdMinus {edge.e1} {edge.e2} {edge.cost:.2f} (reasons {reasons_str})) (STV 1.0 1.0))") + + return atoms + +def build_senf_from_atoms(atoms: List[str]) -> SENF: + senf = SENF(raw_atoms=atoms) + + for atom in atoms: + # Look for (IsA ) or (Inheritance ) + isa_match = re.search(r"\((?:IsA|Inheritance)\s+([A-Za-z0-9_]+)\s+([A-Za-z0-9_]+)\)", atom) + if isa_match: + ent_id = isa_match.group(1) + kind = isa_match.group(2) + if not ent_id.startswith("$") and not ent_id.startswith("?"): # Skip variables + if ent_id not in senf.entities: + senf.entities[ent_id] = SENFEntity(id=ent_id) + senf.entities[ent_id].kind = kind + + # Also extract entities from other simple binary predicates + # like (Predicate ) just so they exist in senf.entities + binary_match = re.search(r"\([A-Za-z0-9_]+\s+([A-Za-z0-9_]+)\s+([A-Za-z0-9_]+)\)", atom) + if binary_match: + for ent_id in (binary_match.group(1), binary_match.group(2)): + if not ent_id.startswith("$") and not ent_id.startswith("?"): + if ent_id not in senf.entities: + senf.entities[ent_id] = SENFEntity(id=ent_id) + + # Extract entities from unary predicates like (Healthy football) + unary_match = re.search(r"\([A-Z][A-Za-z0-9_]*\s+([a-z0-9_]+)\)", atom) + if unary_match: + ent_id = unary_match.group(1) + if not ent_id.startswith("$") and not ent_id.startswith("?"): + if ent_id not in senf.entities: + senf.entities[ent_id] = SENFEntity(id=ent_id) + + return senf diff --git a/core/service.py b/core/service.py index 8a7aa0c..49515e3 100644 --- a/core/service.py +++ b/core/service.py @@ -281,14 +281,6 @@ async def query(self, question: str) -> QueryResponse: answer_generation_seconds=0.0, ) - # 3. Add any supporting statements the parser generated for the query - if parse_result.statements: - valid, rejected_local = self._validate_statements(parse_result.statements) - if rejected_local: - for item in rejected_local[:2]: - print(f"[Service] Dropping malformed query-support statement: {item.get('error')}") - self._reasoner.add_statements(valid) - # 4. Run reasoning via PeTTaChainer against ordered candidates t2 = time.perf_counter() proof_traces: List[str] = [] @@ -308,7 +300,7 @@ async def query(self, question: str) -> QueryResponse: for idx, candidate in enumerate(candidates): executed_query = candidate executed_candidate_index = idx - proof_traces = self._reasoner.query(candidate) + proof_traces = self._reasoner.query(candidate, question) if proof_traces: break @@ -335,7 +327,7 @@ async def query(self, question: str) -> QueryResponse: for idx, candidate in enumerate(more, start=(executed_candidate_index or 0) + 1): executed_query = candidate executed_candidate_index = idx - proof_traces = self._reasoner.query(candidate) + proof_traces = self._reasoner.query(candidate, question) if proof_traces: break except Exception as exc: @@ -480,6 +472,7 @@ def reset(self, scope: str): def health(self) -> dict: conceptnet = self._conceptnet.status() + synonyms = self._reasoner.synonym_status return { "atomspace_size": self._reasoner.size, "background_atomspace_size": self._reasoner.background_size, @@ -490,5 +483,9 @@ def health(self) -> dict: "conceptnet_vectors_indexed": conceptnet["indexed_count"], "conceptnet_vectors_expected": conceptnet["expected_count"], "conceptnet_last_error": conceptnet["last_error"], + "synonym_resolution_enabled": synonyms["enabled"], + "synonym_cached_pairs": synonyms["cached_pairs"], + "synonym_cached_synonyms": synonyms["cached_synonyms"], + "synonym_last_error": synonyms["last_error"], "status": "degraded" if conceptnet["last_error"] else "ok", } diff --git a/core/symbol_normalization.py b/core/symbol_normalization.py index ca4d092..c942375 100644 --- a/core/symbol_normalization.py +++ b/core/symbol_normalization.py @@ -3,6 +3,21 @@ NORMALIZATION_VERSION = 1 +EQUIVALENT_SYMBOLS = { + "aircraft": "plane", + "association_football": "soccer", + "automobile": "car", + "bicycle": "bike", + "canine": "dog", + "cell_phone": "cellphone", + "couch": "sofa", + "feline": "cat", + "football": "soccer", + "infant": "baby", + "mobile_phone": "cellphone", + "physician": "doctor", +} + def singularize(word: str) -> str: if len(word) <= 3: @@ -38,6 +53,14 @@ def canonical_symbol(token: str, lemmatize: bool = True, protect: bool = False) return token +def equivalent_symbol(left: str, right: str) -> bool: + left_symbol = canonical_symbol(left) + right_symbol = canonical_symbol(right) + left_symbol = EQUIVALENT_SYMBOLS.get(left_symbol, left_symbol) + right_symbol = EQUIVALENT_SYMBOLS.get(right_symbol, right_symbol) + return left_symbol == right_symbol + + def normalize_text(text: str) -> str: text = text.lower().replace("-", " ") text = re.sub(r"[^a-z0-9\s]", " ", text) diff --git a/core/synonym_resolver.py b/core/synonym_resolver.py new file mode 100644 index 0000000..c217c87 --- /dev/null +++ b/core/synonym_resolver.py @@ -0,0 +1,471 @@ +import json +import math +import os +import threading +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from typing import Any, Iterable + +import httpx +from openai import OpenAI + +from config import get_settings +from core.horn_fallback import parse_expression +from core.symbol_normalization import canonical_symbol, equivalent_symbol + + +RELATIONS = { + "same_meaning", + "broader", + "narrower", + "related", + "unrelated", + "ambiguous", +} + +IGNORED_SYMBOLS = { + ":", + "Implication", + "Premises", + "Conclusions", + "And", + "Or", + "Not", + "STV", +} + + +@dataclass(frozen=True) +class SynonymDecision: + left: str + right: str + relation: str + confidence: float + source: str + reason: str + updated_at: str + + +class SynonymResolver: + def __init__( + self, + settings: Any | None = None, + http_client: httpx.Client | None = None, + openai_client: OpenAI | None = None, + ): + self._settings = settings or get_settings() + self._enabled = bool(self._settings.synonym_resolution_enabled) + self._cache_path = self._settings.synonym_cache_path + self._http = http_client or httpx.Client( + timeout=float(self._settings.synonym_request_timeout) + ) + self._openai = openai_client + self._lock = threading.RLock() + self._records: dict[str, SynonymDecision] = {} + self._wordnet_cache: dict[str, set[str]] = {} + self._conceptnet_cache: dict[str, set[str]] = {} + self._embedding_cache: dict[str, list[float]] = {} + self._last_error = "" + self._load_cache() + + def discover_equivalences( + self, + query_atom: str, + statements: Iterable[str], + context: str = "", + ) -> set[tuple[str, str]]: + if not self._enabled: + return set() + + query_expression = parse_expression(query_atom) + query_terms = self._terms_from_expression(query_expression) + statement_list = list(statements) + knowledge_terms: set[str] = set() + for statement in statement_list: + expression = parse_expression(statement) + if ( + isinstance(expression, list) + and len(expression) >= 3 + and expression[0] == ":" + ): + expression = expression[2] + knowledge_terms.update(self._terms_from_expression(expression)) + + query_terms = sorted(query_terms) + knowledge_terms = sorted(knowledge_terms) + if not query_terms or not knowledge_terms: + return set() + + approved: set[tuple[str, str]] = set() + candidates: dict[tuple[str, str], set[str]] = {} + + for left in query_terms: + for right in knowledge_terms: + if equivalent_symbol(left, right): + approved.add((left, right)) + continue + cached = self._cached_decision(left, right) + if cached: + if cached.relation == "same_meaning": + approved.add((left, right)) + continue + if any(left == approved_left for approved_left, _ in approved): + continue + lexical = self._wordnet_candidates(left) + lexical.update(self._conceptnet_candidates(left)) + for right in knowledge_terms: + if canonical_symbol(right) in lexical: + candidates.setdefault((left, right), set()).add("lexical") + + remaining_budget = max( + 0, + int(self._settings.synonym_max_verifications_per_query), + ) + remaining_budget = self._verify_candidates( + candidates, + approved, + context, + remaining_budget, + ) + + if remaining_budget > 0 and self._settings.synonym_embedding_enabled: + embedding_candidates = self._embedding_candidates( + query_terms, + knowledge_terms, + approved, + ) + remaining_budget = self._verify_candidates( + embedding_candidates, + approved, + context, + remaining_budget, + ) + + return approved + + def _verify_candidates( + self, + candidates: dict[tuple[str, str], set[str]], + approved: set[tuple[str, str]], + context: str, + budget: int, + ) -> int: + for pair in sorted(candidates): + if budget <= 0: + break + left, right = pair + cached = self._cached_decision(left, right) + if cached: + if cached.relation == "same_meaning": + approved.add(pair) + continue + sources = "+".join(sorted(candidates[pair])) + decision = self._verify_relation(left, right, context, sources) + budget -= 1 + if decision and decision.relation == "same_meaning": + approved.add(pair) + return budget + + def _wordnet_candidates(self, term: str) -> set[str]: + normalized = canonical_symbol(term) + if normalized in self._wordnet_cache: + return set(self._wordnet_cache[normalized]) + candidates: set[str] = set() + if self._settings.synonym_wordnet_enabled: + try: + from nltk.corpus import wordnet + + lookup_forms = {normalized, normalized.replace("_", " ")} + for lookup in lookup_forms: + for synset in wordnet.synsets(lookup): + for lemma in synset.lemma_names(): + candidate = canonical_symbol(lemma) + if candidate: + candidates.add(candidate) + except Exception as exc: + self._set_error(f"WordNet lookup failed: {exc}") + self._wordnet_cache[normalized] = candidates + return set(candidates) + + def _conceptnet_candidates(self, term: str) -> set[str]: + normalized = canonical_symbol(term) + if normalized in self._conceptnet_cache: + return set(self._conceptnet_cache[normalized]) + candidates: set[str] = set() + if self._settings.synonym_conceptnet_lookup_enabled: + try: + response = self._http.get( + f"{self._settings.synonym_conceptnet_url.rstrip('/')}/query", + params={ + "node": f"/c/en/{normalized}", + "rel": "/r/Synonym", + "limit": int(self._settings.synonym_conceptnet_limit), + }, + ) + response.raise_for_status() + for edge in response.json().get("edges", []): + for endpoint in ("start", "end"): + node = edge.get(endpoint, {}) + if node.get("language") != "en": + continue + candidate = canonical_symbol( + node.get("label") + or str(node.get("@id", "")).removeprefix("/c/en/") + ) + if candidate and candidate != normalized: + candidates.add(candidate) + except Exception as exc: + self._set_error(f"ConceptNet lookup failed: {exc}") + self._conceptnet_cache[normalized] = candidates + return set(candidates) + + def _embedding_candidates( + self, + query_terms: list[str], + knowledge_terms: list[str], + approved: set[tuple[str, str]], + ) -> dict[tuple[str, str], set[str]]: + candidates: dict[tuple[str, str], set[str]] = {} + capped_terms = knowledge_terms[ + : max(0, int(self._settings.synonym_max_knowledge_terms)) + ] + top_k = max(1, int(self._settings.synonym_embedding_top_k)) + threshold = float(self._settings.synonym_embedding_threshold) + + for left in query_terms: + if any(left == approved_left for approved_left, _ in approved): + continue + left_vector = self._embedding(left) + if not left_vector: + continue + ranked: list[tuple[float, str]] = [] + for right in capped_terms: + if equivalent_symbol(left, right) or self._cached_decision(left, right): + continue + right_vector = self._embedding(right) + if not right_vector: + continue + score = self._cosine_similarity(left_vector, right_vector) + if score >= threshold: + ranked.append((score, right)) + for _, right in sorted(ranked, reverse=True)[:top_k]: + candidates.setdefault((left, right), set()).add("embedding") + return candidates + + def _embedding(self, term: str) -> list[float]: + normalized = canonical_symbol(term) + if normalized in self._embedding_cache: + return self._embedding_cache[normalized] + try: + response = self._http.post( + self._settings.ollama_url, + json={ + "model": self._settings.ollama_model, + "prompt": normalized.replace("_", " "), + }, + ) + response.raise_for_status() + payload = response.json() + vector = payload.get("embedding") + if vector is None: + embeddings = payload.get("embeddings", []) + vector = embeddings[0] if embeddings else [] + result = [float(value) for value in vector] + self._embedding_cache[normalized] = result + return result + except Exception as exc: + self._set_error(f"Ollama embedding failed: {exc}") + self._embedding_cache[normalized] = [] + return [] + + def _verify_relation( + self, + left: str, + right: str, + context: str, + sources: str, + ) -> SynonymDecision | None: + try: + client = self._openai or OpenAI(api_key=self._settings.openai_api_key) + model = str( + self._settings.synonym_verifier_model + or self._settings.openai_model + ) + if model.startswith("openai/"): + model = model.split("/", 1)[1] + response = client.responses.create( + model=model, + temperature=0, + input=[ + { + "role": "system", + "content": ( + "Classify the lexical relation between two terms for a logic " + "engine. Use same_meaning only when the terms denote the same " + "concept and can safely substitute for each other without " + "changing truth. Do not call broader, narrower, merely related, " + "or contextually associated terms synonyms." + ), + }, + { + "role": "user", + "content": ( + f"Term A: {left.replace('_', ' ')}\n" + f"Term B: {right.replace('_', ' ')}\n" + f"Candidate sources: {sources}\n" + f"Question context: {context or 'none'}" + ), + }, + ], + text={ + "format": { + "type": "json_schema", + "name": "synonym_relation", + "strict": True, + "schema": { + "type": "object", + "properties": { + "relation": { + "type": "string", + "enum": sorted(RELATIONS), + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1, + }, + "reason": {"type": "string"}, + }, + "required": ["relation", "confidence", "reason"], + "additionalProperties": False, + }, + } + }, + ) + parsed = json.loads(response.output_text) + relation = str(parsed["relation"]) + confidence = float(parsed["confidence"]) + if ( + relation == "same_meaning" + and confidence + < float(self._settings.synonym_verifier_min_confidence) + ): + relation = "ambiguous" + decision = SynonymDecision( + left=canonical_symbol(left), + right=canonical_symbol(right), + relation=relation, + confidence=confidence, + source=f"{sources}+openai", + reason=str(parsed["reason"]), + updated_at=datetime.now(timezone.utc).isoformat(), + ) + self._store_decision(decision) + self._last_error = "" + return decision + except Exception as exc: + self._set_error(f"OpenAI synonym verification failed: {exc}") + return None + + def _cached_decision( + self, + left: str, + right: str, + ) -> SynonymDecision | None: + with self._lock: + return self._records.get(self._pair_key(left, right)) + + def _store_decision(self, decision: SynonymDecision) -> None: + key = self._pair_key(decision.left, decision.right) + with self._lock: + self._records[key] = decision + directory = os.path.dirname(self._cache_path) + if directory: + os.makedirs(directory, exist_ok=True) + temporary = f"{self._cache_path}.tmp" + payload = { + "version": 1, + "pairs": { + item_key: asdict(item) + for item_key, item in sorted(self._records.items()) + }, + } + with open(temporary, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + os.replace(temporary, self._cache_path) + + def _load_cache(self) -> None: + if not os.path.exists(self._cache_path): + return + try: + with open(self._cache_path, encoding="utf-8") as handle: + payload = json.load(handle) + records: dict[str, SynonymDecision] = {} + for key, item in payload.get("pairs", {}).items(): + decision = SynonymDecision(**item) + if decision.relation in RELATIONS: + records[key] = decision + self._records = records + except Exception as exc: + self._set_error(f"Synonym cache load failed: {exc}") + + def _terms_from_expression(self, expression: Any) -> set[str]: + terms: set[str] = set() + + def visit(value: Any) -> None: + if not isinstance(value, list) or not value: + return + for item in value[1:]: + if isinstance(item, list): + visit(item) + continue + if not isinstance(item, str): + continue + if item.startswith(("$", "?")) or item in IGNORED_SYMBOLS: + continue + normalized = canonical_symbol(item) + if ( + normalized + and not normalized.replace("_", "").isdigit() + and normalized not in {"stv"} + ): + terms.add(normalized) + + visit(expression) + return terms + + def _pair_key(self, left: str, right: str) -> str: + normalized = sorted((canonical_symbol(left), canonical_symbol(right))) + return "|".join(normalized) + + def _cosine_similarity( + self, + left: list[float], + right: list[float], + ) -> float: + if not left or not right or len(left) != len(right): + return 0.0 + dot = sum(a * b for a, b in zip(left, right)) + left_norm = math.sqrt(sum(value * value for value in left)) + right_norm = math.sqrt(sum(value * value for value in right)) + if not left_norm or not right_norm: + return 0.0 + return dot / (left_norm * right_norm) + + def _set_error(self, error: str) -> None: + self._last_error = error + print(f"[SynonymResolver] {error}") + + def status(self) -> dict[str, Any]: + with self._lock: + same_meaning_count = sum( + 1 + for decision in self._records.values() + if decision.relation == "same_meaning" + ) + return { + "enabled": self._enabled, + "cached_pairs": len(self._records), + "cached_synonyms": same_meaning_count, + "last_error": self._last_error, + } diff --git a/core/synonyms.py b/core/synonyms.py new file mode 100644 index 0000000..08c995d --- /dev/null +++ b/core/synonyms.py @@ -0,0 +1,129 @@ +import os +import json +import httpx +import dspy +from typing import Dict, List +from config import get_settings + + +class _CompareWords(dspy.Signature): + """ + You are a strict linguist. Compare the following two words. + Are they exactly the same meaning (synonyms), is one broader, narrower, or are they just related/unrelated? + Note: Treat regional variations of the exact same underlying concept (like "football" and "soccer", or "attorney" and "lawyer") as EXACT synonyms ("same_meaning"). + Respond ONLY with one of the following exact words: + same_meaning + broader + narrower + related + unrelated + """ + word1: str = dspy.InputField() + word2: str = dspy.InputField() + relationship: str = dspy.OutputField(desc="Must be exactly one of: same_meaning, broader, narrower, related, unrelated") + + +class HybridSynonymVerifier: + """ + Verifies if two words are synonyms using DSPy/LLM with a strict prompt. + Caches verified relationships in data/synonyms/relations.json. + """ + def __init__(self): + cfg = get_settings() + self._cache_file = "data/synonyms/relations.json" + self._cache: Dict[str, str] = {} + self._embedding_cache: Dict[str, List[float]] = {} + + self._ollama_url = cfg.ollama_url + self._ollama_model = cfg.ollama_model + self._client = httpx.Client(timeout=10) + + self._load_cache() + + try: + if not dspy.settings.lm: + lm = dspy.LM(cfg.openai_model, api_key=cfg.openai_api_key, cache=False) + dspy.configure(lm=lm, temperature=0.0) + except Exception: + lm = dspy.LM(cfg.openai_model, api_key=cfg.openai_api_key, cache=False) + dspy.configure(lm=lm, temperature=0.0) + + self._predict = dspy.Predict(_CompareWords) + + def _load_cache(self): + os.makedirs(os.path.dirname(self._cache_file), exist_ok=True) + if os.path.exists(self._cache_file): + try: + with open(self._cache_file, "r") as f: + self._cache = json.load(f) + except Exception: + self._cache = {} + + def _save_cache(self): + with open(self._cache_file, "w") as f: + json.dump(self._cache, f, indent=2) + + def _get_embedding(self, text: str) -> List[float]: + text = text.lower().replace("_", " ") + if text in self._embedding_cache: + return self._embedding_cache[text] + try: + resp = self._client.post(self._ollama_url, json={ + "model": self._ollama_model, + "prompt": text + }) + resp.raise_for_status() + emb = resp.json()["embedding"] + self._embedding_cache[text] = emb + return emb + except Exception: + return [] + + def _cosine_similarity(self, v1: List[float], v2: List[float]) -> float: + if not v1 or not v2 or len(v1) != len(v2): + return 0.0 + dot = sum(a * b for a, b in zip(v1, v2)) + mag1 = sum(a * a for a in v1) ** 0.5 + mag2 = sum(b * b for b in v2) ** 0.5 + if mag1 == 0 or mag2 == 0: + return 0.0 + return dot / (mag1 * mag2) + + def get_candidates(self, query_word: str, target_words: List[str], top_k: int = 1, threshold: float = 0.5) -> List[str]: + q_emb = self._get_embedding(query_word) + if not q_emb: + return [] + + scores = [] + for word in target_words: + w_emb = self._get_embedding(word) + if not w_emb: + continue + sim = self._cosine_similarity(q_emb, w_emb) + if sim >= threshold: + scores.append((word, sim)) + + scores.sort(key=lambda x: x[1], reverse=True) + return [word for word, sim in scores[:top_k]] + + def verify_synonym(self, word1: str, word2: str) -> str: + """Returns 'same_meaning', 'broader', 'narrower', 'related', 'unrelated'""" + w1, w2 = sorted([word1.lower(), word2.lower()]) + cache_key = f"{w1}::{w2}" + if cache_key in self._cache: + return self._cache[cache_key] + + try: + result = self._predict(word1=w1, word2=w2).relationship.strip().lower() + valid = ["same_meaning", "broader", "narrower", "related", "unrelated"] + for v in valid: + if v in result: + self._cache[cache_key] = v + self._save_cache() + return v + except Exception as e: + print(f"[HybridVerifier] Error verifying: {e}") + + self._cache[cache_key] = "unrelated" + self._save_cache() + return "unrelated" diff --git a/core/transweave.py b/core/transweave.py new file mode 100644 index 0000000..2b58b34 --- /dev/null +++ b/core/transweave.py @@ -0,0 +1,90 @@ +from dataclasses import dataclass, field +from typing import List, Dict, Tuple +from core.senf import SENF, SENFEntity +from core.symbol_normalization import equivalent_symbol + +@dataclass +class Weave: + id: str + sa_id: str + sb_id: str + cost: float + distortion: float + entity_map: Dict[str, str] = field(default_factory=dict) + kind_map: Dict[str, str] = field(default_factory=dict) + exemplar_map: Dict[str, str] = field(default_factory=dict) + + def to_metta_strings(self) -> List[str]: + """Serialize TransWeave data to MeTTa atoms.""" + atoms = [] + atoms.append(f"(: weave_{self.id} (Weave {self.id} {self.sa_id} {self.sb_id}) (STV 1.0 1.0))") + atoms.append(f"(: weave_cost_{self.id} (WeaveCost {self.id} {self.cost:.2f}) (STV 1.0 1.0))") + atoms.append(f"(: weave_dist_{self.id} (WeaveDistortion {self.id} {self.distortion:.2f}) (STV 1.0 1.0))") + + for i, (e_a, e_b) in enumerate(self.entity_map.items()): + atoms.append(f"(: map_entity_{self.id}_{i} (MapEntity {self.id} {e_a} {e_b}) (STV 1.0 1.0))") + + for i, (k_a, k_b) in enumerate(self.kind_map.items()): + atoms.append(f"(: map_kind_{self.id}_{i} (MapKind {self.id} {k_a} {k_b}) (STV 1.0 1.0))") + + return atoms + +class TransWeaveAligner: + """ + Implements a single-shot alignment algorithm (Algorithm 1) to find + structural and semantic mappings between two SENFs. + """ + + def build_weaves(self, senf_a: SENF, senf_b: SENF, weave_id: str = "W1", sa_id: str = "SA", sb_id: str = "SB", top_k: int = 1) -> List[Weave]: + """ + Build top-k weaves between two SENFs. For this MVP, we implement + a greedy single-shot matcher that prefers same-kind and close exemplars. + """ + candidate_pairs: List[Tuple[SENFEntity, SENFEntity, float]] = [] + + # Cross product of entities to find candidate matches + for ent_a in senf_a.entities.values(): + for ent_b in senf_b.entities.values(): + if ent_a.id == ent_b.id: + continue + cost = self._score_entity_pair(ent_a, ent_b, senf_a, senf_b) + if cost < 1.0: # threshold to consider a match + candidate_pairs.append((ent_a, ent_b, cost)) + + # Sort by lowest cost (best match) + candidate_pairs.sort(key=lambda x: x[2]) + + # Greedy assignment to build one weave + assigned_a = set() + assigned_b = set() + + weave = Weave(id=weave_id, sa_id=sa_id, sb_id=sb_id, cost=0.0, distortion=0.0) + + for ent_a, ent_b, cost in candidate_pairs: + if ent_a.id not in assigned_a and ent_b.id not in assigned_b: + # Add to weave + weave.entity_map[ent_a.id] = ent_b.id + + if ent_a.kind and ent_b.kind: + weave.kind_map[ent_a.kind] = ent_b.kind + + # Add cost + weave.cost += cost + + # Mark as assigned + assigned_a.add(ent_a.id) + assigned_b.add(ent_b.id) + + # Only return a weave if we mapped something + if weave.entity_map: + return [weave] + return [] + + def _score_entity_pair(self, ent_a: SENFEntity, ent_b: SENFEntity, senf_a: SENF, senf_b: SENF) -> float: + return 0.0 if equivalent_symbol(ent_a.id, ent_b.id) else 1.0 + + def _get_best_exemplar(self, entity_id: str, senf: SENF): + exs = [ex for ex in senf.exemplars if ex.entity_id == entity_id] + if not exs: + return None + return min(exs, key=lambda x: x.distance) diff --git a/data/transweave_rules.metta b/data/transweave_rules.metta new file mode 100644 index 0000000..52ea816 --- /dev/null +++ b/data/transweave_rules.metta @@ -0,0 +1,15 @@ +;; Phase 6: TransWeave & PLN Bridge Rules +;; These rules instruct the PeTTaChainer how to traverse SimilarityLinks. +;; When a query asks for a fact about $x, but the knowledge base only has +;; a fact about $y, the chainer can legally substitute $y for $x IF there +;; is a SimilarityLink between them. The final TruthValue is multiplied +;; by the SimilarityLink's confidence, inherently discounting analogies. + +(: SimilarityTransportRule (Implication (Premises (SimilarityLink $x $y) (Inheritance $x $z)) (Conclusions (Inheritance $y $z))) (STV 1.0 1.0)) +(: SimilarityTransportRuleRev (Implication (Premises (SimilarityLink $x $y) (Inheritance $z $x)) (Conclusions (Inheritance $z $y))) (STV 1.0 1.0)) +(: SimilarityTransportRelation (Implication (Premises (SimilarityLink $x $y) (Evaluation $rel (List $x $z))) (Conclusions (Evaluation $rel (List $y $z)))) (STV 1.0 1.0)) +(: SimilarityTransportRelationRev (Implication (Premises (SimilarityLink $x $y) (Evaluation $rel (List $z $x))) (Conclusions (Evaluation $rel (List $z $y)))) (STV 1.0 1.0)) +(: SimilarityTransportRuleIsA (Implication (Premises (SimilarityLink $x $y) (IsA $x $z)) (Conclusions (IsA $y $z))) (STV 1.0 1.0)) +(: SimilarityTransportRuleIsARev (Implication (Premises (SimilarityLink $x $y) (IsA $z $x)) (Conclusions (IsA $z $y))) (STV 1.0 1.0)) +(: SimilarityTransportUnary (Implication (Premises (SimilarityLink $x $y) ($P $x)) (Conclusions ($P $y))) (STV 1.0 1.0)) +(: SimilarityTransportRuleHealthy (Implication (Premises (SimilarityLink $x $y) (Healthy $x)) (Conclusions (Healthy $y))) (STV 1.0 1.0)) diff --git a/docker-compose.yml b/docker-compose.yml index 701e0d1..a5a9a26 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,13 +19,28 @@ services: - LANGEXTRACT_CHUNK_SIZE=${LANGEXTRACT_CHUNK_SIZE:-512} - QDRANT_URL=http://qdrant:6333 # Note: Points to Ollama on your host machine - - OLLAMA_URL=http://host.docker.internal:11434/api/embeddings + - OLLAMA_URL=${OLLAMA_URL:-http://host.docker.internal:11434/api/embeddings} + - OLLAMA_MODEL=${OLLAMA_MODEL:-nomic-embed-text} - ATOMSPACE_PATH=/app/data/atomspace/kb.metta - FAISS_PATH=/app/data/faiss - CHAINING_TIMEOUT=${CHAINING_TIMEOUT:-180} - CHAINING_MAX_STEPS=${CHAINING_MAX_STEPS:-100} - QUERY_FALLBACK_ENABLED=${QUERY_FALLBACK_ENABLED:-true} - QUERY_CANDIDATE_MAX_TRIES=${QUERY_CANDIDATE_MAX_TRIES:-5} + - SYNONYM_RESOLUTION_ENABLED=${SYNONYM_RESOLUTION_ENABLED:-true} + - SYNONYM_CACHE_PATH=/app/data/synonyms/relations.json + - SYNONYM_WORDNET_ENABLED=${SYNONYM_WORDNET_ENABLED:-true} + - SYNONYM_CONCEPTNET_LOOKUP_ENABLED=${SYNONYM_CONCEPTNET_LOOKUP_ENABLED:-true} + - SYNONYM_CONCEPTNET_URL=${SYNONYM_CONCEPTNET_URL:-https://api.conceptnet.io} + - SYNONYM_CONCEPTNET_LIMIT=${SYNONYM_CONCEPTNET_LIMIT:-50} + - SYNONYM_EMBEDDING_ENABLED=${SYNONYM_EMBEDDING_ENABLED:-true} + - SYNONYM_EMBEDDING_THRESHOLD=${SYNONYM_EMBEDDING_THRESHOLD:-0.68} + - SYNONYM_EMBEDDING_TOP_K=${SYNONYM_EMBEDDING_TOP_K:-3} + - SYNONYM_MAX_KNOWLEDGE_TERMS=${SYNONYM_MAX_KNOWLEDGE_TERMS:-64} + - SYNONYM_MAX_VERIFICATIONS_PER_QUERY=${SYNONYM_MAX_VERIFICATIONS_PER_QUERY:-6} + - SYNONYM_VERIFIER_MODEL=${SYNONYM_VERIFIER_MODEL:-gpt-4o-mini} + - SYNONYM_VERIFIER_MIN_CONFIDENCE=${SYNONYM_VERIFIER_MIN_CONFIDENCE:-0.85} + - SYNONYM_REQUEST_TIMEOUT=${SYNONYM_REQUEST_TIMEOUT:-10} - CONCEPTNET_ENABLED=${CONCEPTNET_ENABLED:-false} - CONCEPTNET_AUTOLOAD=${CONCEPTNET_AUTOLOAD:-true} - CONCEPTNET_INPUT_FILE=/app/data/conceptnet/conceptnet-assertions-5.7.0.csv.gz diff --git a/parsers/__init__.py b/parsers/__init__.py index daa45c2..613bc52 100644 --- a/parsers/__init__.py +++ b/parsers/__init__.py @@ -35,6 +35,11 @@ def get_parser() -> SemanticParser: return CanonicalLangExtractParser() + if name == "canonical_senf_pln": + from parsers.canonical_senf_pln_parser import CanonicalSenfPlnParser + + return CanonicalSenfPlnParser() + raise ValueError( - f"Unknown parser '{name}'. Set PARSER to one of: nl2pln, canonical_pln, manhin, langextract, canonical_langextract" + f"Unknown parser '{name}'. Set PARSER to one of: nl2pln, canonical_pln, manhin, langextract, canonical_langextract, canonical_senf_pln" ) diff --git a/parsers/canonical_pln_parser.py b/parsers/canonical_pln_parser.py index 73dd88d..711cec8 100644 --- a/parsers/canonical_pln_parser.py +++ b/parsers/canonical_pln_parser.py @@ -128,6 +128,9 @@ def _parse_many_with_mode( statements = self._dedupe_preserve_order( statements + self._materialize_grounded_premise_facts(texts, statements) ) + statements = self._dedupe_preserve_order( + statements + self._materialize_simple_copular_facts(texts, statements) + ) question_text = " ".join(texts) queries = self._plan_queries(question=question_text, queries=queries, statements=statements, context=context) @@ -199,6 +202,38 @@ def _build_parser_inputs_batch( enriched_context = self._dedupe_preserve_order(context + hint_lines) return prepared_texts, enriched_context + def _materialize_simple_copular_facts( + self, + texts: List[str], + statements: List[str], + ) -> List[str]: + existing = { + (signature["head"], tuple(signature["args"])) + for statement in statements + for signature in self._extract_fact_signatures(statement) + } + facts: List[str] = [] + for text in texts: + normalized = self._normalize_text(text) + match = re.fullmatch( + r"(?:a |an |the )?([a-z0-9_-]+) (?:is|are|was|were) " + r"(?:a |an |the )?([a-z0-9_-]+)", + normalized, + ) + if not match: + continue + subject = self._canonical_symbol(match.group(1)) + target = self._canonical_symbol(match.group(2)) + signature = ("IsA", (subject, target)) + if not subject or not target or signature in existing: + continue + facts.append( + f"(: canonical_{subject}_{target}_fact " + f"(IsA {subject} {target}) (STV 1.0 1.0))" + ) + existing.add(signature) + return facts + def _build_parser_inputs( self, text: str, context: List[str], is_query: bool ) -> tuple[str, List[str]]: @@ -552,6 +587,23 @@ def _build_heuristic_question_queries(self, question: str) -> List[str]: target = self._canonical_phrase(match.group(2)) if subject and target: queries.append(f"(: $prf ({head} {subject} {target}) $tv)") + copular_tokens = normalized.split() + if ( + len(copular_tokens) >= 3 + and copular_tokens[0] in {"is", "are", "was", "were"} + ): + subject = self._canonical_phrase(" ".join(copular_tokens[1:-1])) + target = self._canonical_phrase(copular_tokens[-1]) + predicate = "".join( + part.capitalize() + for part in target.split("_") + if part + ) + if subject and target: + queries.append(f"(: $prf (IsA {subject} {target}) $tv)") + if subject and predicate: + queries.append(f"(: $prf ({predicate} {subject}) $tv)") + queries.append(f"(: $prf (Is{predicate} {subject}) $tv)") return queries def _canonical_phrase(self, phrase: str) -> str: diff --git a/parsers/canonical_senf_pln_parser.py b/parsers/canonical_senf_pln_parser.py new file mode 100644 index 0000000..46bacaa --- /dev/null +++ b/parsers/canonical_senf_pln_parser.py @@ -0,0 +1,75 @@ +from typing import List +from core.parser import ParseResult, SemanticParser +from parsers.canonical_pln_parser import CanonicalPLNParser +from core.senf import build_senf_from_atoms +from core.exemplar_registry import ExemplarScorer +from core.identity_graph import IdentityGraphBuilder +from core.transweave import TransWeaveAligner +from core.pln_bridge import PLNBridgeGenerator + +class CanonicalSenfPlnParser(SemanticParser): + def __init__(self): + self.base_parser = CanonicalPLNParser() + self.scorer = ExemplarScorer() + self.identity_builder = IdentityGraphBuilder() + self.aligner = TransWeaveAligner() + self.bridge_generator = PLNBridgeGenerator() + + def parse(self, text: str, context: List[str]) -> ParseResult: + result = self.base_parser.parse(text, context) + return self._enrich_result(result, text, context) + + def parse_batch(self, texts: List[str], context: List[str]) -> ParseResult: + result = self.base_parser.parse_batch(texts, context) + full_text = " ".join(texts) + return self._enrich_result(result, full_text, context) + + def _enrich_result(self, result: ParseResult, context_text: str, context_atoms: List[str]) -> ParseResult: + # Build context SENF for TransWeave + context_senf = None + if context_atoms: + context_senf = build_senf_from_atoms(context_atoms) + self.scorer.score(context_senf, "") + + # Enrich statements + if result.statements: + senf = build_senf_from_atoms(result.statements) + self.scorer.score(senf, context_text) + self.identity_builder.build_graph(senf) + + # Phase 4: TransWeave against context if available + if context_senf: + weaves = self.aligner.build_weaves(context_senf, senf, weave_id="W_stmt", sa_id="Context", sb_id="Statement") + for w in weaves: + senf.raw_atoms.extend(w.to_metta_strings()) + # Phase 5: PLN Bridge Generation + bridge_atoms = self.bridge_generator.generate_bridges(w) + senf.raw_atoms.extend(bridge_atoms) + + result.statements = senf.to_metta_strings() + + # Enrich queries + if result.queries: + query_senf = build_senf_from_atoms(result.queries) + self.scorer.score(query_senf, context_text) + self.identity_builder.build_graph(query_senf) + + # Phase 4: TransWeave against context if available + if context_senf: + weaves = self.aligner.build_weaves(context_senf, query_senf, weave_id="W_query", sa_id="Context", sb_id="Query") + for w in weaves: + # Put the weaves and bridges into STATEMENTS so the reasoner loads them + if not result.statements: + result.statements = [] + result.statements.extend(w.to_metta_strings()) + # Phase 5: PLN Bridge Generation + result.statements.extend(self.bridge_generator.generate_bridges(w)) + + return result + + def parse_query(self, text: str, context: List[str]) -> ParseResult: + if hasattr(self.base_parser, "parse_query"): + result = self.base_parser.parse_query(text, context) + else: + result = self.base_parser.parse(text, context) + return self._enrich_result(result, text, context) diff --git a/requirements.txt b/requirements.txt index 88a554e..c69cb1e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,5 +14,6 @@ sexpdata>=1.0.2 httpx>=0.27.0 python-dotenv>=1.0.0 more-itertools>=10.0.0 +nltk>=3.9.0 langextract>=1.0.0 git+https://github.com/rTreutlein/NL2PLN.git@feature/pln-premises-conclusions diff --git a/simba_all.json b/simba_all.json deleted file mode 100644 index a5d6207..0000000 --- a/simba_all.json +++ /dev/null @@ -1,180 +0,0 @@ -{ - "nl2pln.predict": { - "traces": [], - "train": [], - "demos": [ - { - "augmented": true, - "sentences": [ - "Fragile items need a double wrap.", - "Anything that needs a double wrap must be in a padded box.", - "Object-77 is fragile." - ], - "context": [], - "pln_spec": "# PeTTaChainer LLM Rule Spec\n\nThis spec focuses only on constructing valid Statements and Queries.\nIt does not describe how to invoke chainer interface functions.\n\n## Core Forms\n\n- Statement form (fact or rule assertion):\n\n```metta\n(: proof-id type tv)\n```\n\n- Query pattern form:\n\n```metta\n(: $proofVar typePattern $tvVar)\n```\n\n## Rule Template\n\n```metta\n(: ruleName\n (Implication\n (Premises\n premise1\n premise2)\n (Conclusions\n conclusion1))\n (STV 1.0 1.0))\n```\n\n## Premise Helpers You Can Use\n\n### Compute\n\n```metta\n(Compute f (arg1 arg2 ...) -> $out)\n```\n\n### Not\n\n```metta\n(Not expr)\n```\n\n### GreaterThan / >\n\n```metta\n(GreaterThan (DistFactA ...) 5)\n(GreaterThan (DistFactA ...) (DistFactB ...))\n```\n\n### MapDist\n\n```metta\n(MapDist f (DistFactA ...) -> $outDist)\n```\n\n### Map2Dist\n\n```metta\n(Map2Dist f (DistFactA ...) (DistFactB ...) -> $outDist)\n```\n\n### AverageDist\n\n```metta\n(AverageDist (DistFactPattern ...) -> $outDist)\n```\n\n### FoldAll / FoldAllValue\n\n```metta\n(FoldAll pattern value init fold-fn -> out)\n(FoldAllValue pattern value init fold-fn -> out)\n```\n\n## TV Modeling Rules\n\n- `STV` is truth uncertainty only.\n- Distribution TVs (`ParticleDist`, `NatDist`, `FloatDist`) are value uncertainty.\n- For uncertain numeric values, use distribution TVs.\n\nGood:\n\n```metta\n(: h1 (HeightDist g1 alice) (PointMass 160.0))\n(: h2 (HeightDist g1 bob) (ParticleFromNormal 170.0 2.0))\n```\n\nAvoid encoding numeric values in `STV` strength for measurement semantics.\n\n## Distribution Constructors\n\n```metta\n(PointMass x)\n(ParticleFromNormal mu sigma)\n(ParticleFromPairs ((x1 w1) (x2 w2) ...))\n```\n\n## Example: Average Height Rule\n\n```metta\n(: avgHeightDistRule\n (Implication\n (Premises\n (Group $g)\n (AverageDist (HeightDist $g $person) -> $avgDist))\n (Conclusions\n (AvgHeightDist $g)))\n (STV 1.0 1.0))\n\n(: $prf (AvgHeightDist g1) $avgDist)\n```\n\n## Example: Rectangle Area Rule\n\n```metta\n(: areaDistRule\n (Implication\n (Premises\n (Rectangle $rect)\n (Map2Dist * (LengthDist $rect) (WidthDist $rect) -> $areaDist))\n (Conclusions\n (AreaDist $rect)))\n (STV 1.0 1.0))\n\n(: $prf (AreaDist rectA) $areaDist)\n```\n", - "reasoning": "This is a transitive chain. I must create a rule linking Fragile to NeedsDoubleWrap, and a second rule linking NeedsDoubleWrap to InPaddedBox. I must use exact predicate matching and snake_case for the object object_77.", - "statements": [ - "(: fragileRule (Implication (Premises (Fragile $i)) (Conclusions (NeedsDoubleWrap $i))) (STV 1.0 1.0))", - "(: wrapRule (Implication (Premises (NeedsDoubleWrap $i)) (Conclusions (InPaddedBox $i))) (STV 1.0 1.0))", - "(: obj77Fact (Fragile object_77) (STV 1.0 1.0))" - ], - "queries": [ - "(: $prf (InPaddedBox object_77) $tv)" - ] - }, - { - "augmented": true, - "sentences": [ - "Dr. Ayele is an expert in quantum physics.", - "Kebede is a student of Dr. Ayele.", - "If a person is a student of an expert, they know the topic." - ], - "context": [], - "pln_spec": "# PeTTaChainer LLM Rule Spec\n\nThis spec focuses only on constructing valid Statements and Queries.\nIt does not describe how to invoke chainer interface functions.\n\n## Core Forms\n\n- Statement form (fact or rule assertion):\n\n```metta\n(: proof-id type tv)\n```\n\n- Query pattern form:\n\n```metta\n(: $proofVar typePattern $tvVar)\n```\n\n## Rule Template\n\n```metta\n(: ruleName\n (Implication\n (Premises\n premise1\n premise2)\n (Conclusions\n conclusion1))\n (STV 1.0 1.0))\n```\n\n## Premise Helpers You Can Use\n\n### Compute\n\n```metta\n(Compute f (arg1 arg2 ...) -> $out)\n```\n\n### Not\n\n```metta\n(Not expr)\n```\n\n### GreaterThan / >\n\n```metta\n(GreaterThan (DistFactA ...) 5)\n(GreaterThan (DistFactA ...) (DistFactB ...))\n```\n\n### MapDist\n\n```metta\n(MapDist f (DistFactA ...) -> $outDist)\n```\n\n### Map2Dist\n\n```metta\n(Map2Dist f (DistFactA ...) (DistFactB ...) -> $outDist)\n```\n\n### AverageDist\n\n```metta\n(AverageDist (DistFactPattern ...) -> $outDist)\n```\n\n### FoldAll / FoldAllValue\n\n```metta\n(FoldAll pattern value init fold-fn -> out)\n(FoldAllValue pattern value init fold-fn -> out)\n```\n\n## TV Modeling Rules\n\n- `STV` is truth uncertainty only.\n- Distribution TVs (`ParticleDist`, `NatDist`, `FloatDist`) are value uncertainty.\n- For uncertain numeric values, use distribution TVs.\n\nGood:\n\n```metta\n(: h1 (HeightDist g1 alice) (PointMass 160.0))\n(: h2 (HeightDist g1 bob) (ParticleFromNormal 170.0 2.0))\n```\n\nAvoid encoding numeric values in `STV` strength for measurement semantics.\n\n## Distribution Constructors\n\n```metta\n(PointMass x)\n(ParticleFromNormal mu sigma)\n(ParticleFromPairs ((x1 w1) (x2 w2) ...))\n```\n\n## Example: Average Height Rule\n\n```metta\n(: avgHeightDistRule\n (Implication\n (Premises\n (Group $g)\n (AverageDist (HeightDist $g $person) -> $avgDist))\n (Conclusions\n (AvgHeightDist $g)))\n (STV 1.0 1.0))\n\n(: $prf (AvgHeightDist g1) $avgDist)\n```\n\n## Example: Rectangle Area Rule\n\n```metta\n(: areaDistRule\n (Implication\n (Premises\n (Rectangle $rect)\n (Map2Dist * (LengthDist $rect) (WidthDist $rect) -> $areaDist))\n (Conclusions\n (AreaDist $rect)))\n (STV 1.0 1.0))\n\n(: $prf (AreaDist rectA) $areaDist)\n```\n", - "reasoning": "I need to normalize 'Dr. Ayele' to dr_ayele and 'quantum physics' to quantum_physics. The rule must use a shared variable for the teacher to bridge the StudentOf and ExpertIn relations.", - "statements": [ - "(: dr_ayele_expert (ExpertIn dr_ayele quantum_physics) (STV 1.0 1.0))", - "(: k_student (StudentOf kebede dr_ayele) (STV 1.0 1.0))", - "(: knowRule (Implication (Premises (And (StudentOf $s $t) (ExpertIn $t $topic))) (Conclusions (Knows $s $topic))) (STV 1.0 1.0))" - ], - "queries": [ - "(: $prf (Knows kebede quantum_physics) $tv)" - ] - }, - { - "augmented": true, - "sentences": [ - "Kebede is at rack-01.", - "Rack-01 is inside vault-07.", - "A person is in an area if they are at a spot inside that area." - ], - "context": [], - "pln_spec": "# PeTTaChainer LLM Rule Spec\n\nThis spec focuses only on constructing valid Statements and Queries.\nIt does not describe how to invoke chainer interface functions.\n\n## Core Forms\n\n- Statement form (fact or rule assertion):\n\n```metta\n(: proof-id type tv)\n```\n\n- Query pattern form:\n\n```metta\n(: $proofVar typePattern $tvVar)\n```\n\n## Rule Template\n\n```metta\n(: ruleName\n (Implication\n (Premises\n premise1\n premise2)\n (Conclusions\n conclusion1))\n (STV 1.0 1.0))\n```\n\n## Premise Helpers You Can Use\n\n### Compute\n\n```metta\n(Compute f (arg1 arg2 ...) -> $out)\n```\n\n### Not\n\n```metta\n(Not expr)\n```\n\n### GreaterThan / >\n\n```metta\n(GreaterThan (DistFactA ...) 5)\n(GreaterThan (DistFactA ...) (DistFactB ...))\n```\n\n### MapDist\n\n```metta\n(MapDist f (DistFactA ...) -> $outDist)\n```\n\n### Map2Dist\n\n```metta\n(Map2Dist f (DistFactA ...) (DistFactB ...) -> $outDist)\n```\n\n### AverageDist\n\n```metta\n(AverageDist (DistFactPattern ...) -> $outDist)\n```\n\n### FoldAll / FoldAllValue\n\n```metta\n(FoldAll pattern value init fold-fn -> out)\n(FoldAllValue pattern value init fold-fn -> out)\n```\n\n## TV Modeling Rules\n\n- `STV` is truth uncertainty only.\n- Distribution TVs (`ParticleDist`, `NatDist`, `FloatDist`) are value uncertainty.\n- For uncertain numeric values, use distribution TVs.\n\nGood:\n\n```metta\n(: h1 (HeightDist g1 alice) (PointMass 160.0))\n(: h2 (HeightDist g1 bob) (ParticleFromNormal 170.0 2.0))\n```\n\nAvoid encoding numeric values in `STV` strength for measurement semantics.\n\n## Distribution Constructors\n\n```metta\n(PointMass x)\n(ParticleFromNormal mu sigma)\n(ParticleFromPairs ((x1 w1) (x2 w2) ...))\n```\n\n## Example: Average Height Rule\n\n```metta\n(: avgHeightDistRule\n (Implication\n (Premises\n (Group $g)\n (AverageDist (HeightDist $g $person) -> $avgDist))\n (Conclusions\n (AvgHeightDist $g)))\n (STV 1.0 1.0))\n\n(: $prf (AvgHeightDist g1) $avgDist)\n```\n\n## Example: Rectangle Area Rule\n\n```metta\n(: areaDistRule\n (Implication\n (Premises\n (Rectangle $rect)\n (Map2Dist * (LengthDist $rect) (WidthDist $rect) -> $areaDist))\n (Conclusions\n (AreaDist $rect)))\n (STV 1.0 1.0))\n\n(: $prf (AreaDist rectA) $areaDist)\n```\n", - "reasoning": "To prevent losing the area variable in the query, I must use a binary predicate (InArea $p $a). I will map 'rack-01' to rack_01 and 'vault-07' to vault_07.", - "statements": [ - "(: k_loc (AtSpot kebede rack_01) (STV 1.0 1.0))", - "(: spot_in_area (Inside rack_01 vault_07) (STV 1.0 1.0))", - "(: spatialRule (Implication (Premises (And (AtSpot $p $s) (Inside $s $a))) (Conclusions (InArea $p $a))) (STV 1.0 1.0))" - ], - "queries": [ - "(: $prf (InArea kebede vault_07) $tv)" - ] - }, - { - "augmented": true, - "sentences": [ - "An electric motor is a part of an electric fan.", - "An electric motor converts electrical energy into mechanical energy.", - "A fan is a kind of appliance." - ], - "context": [], - "pln_spec": "# PeTTaChainer LLM Rule Spec\n\nThis spec focuses only on constructing valid Statements and Queries.\nIt does not describe how to invoke chainer interface functions.\n\n## Core Forms\n\n- Statement form (fact or rule assertion):\n\n```metta\n(: proof-id type tv)\n```\n\n- Query pattern form:\n\n```metta\n(: $proofVar typePattern $tvVar)\n```\n\n## Rule Template\n\n```metta\n(: ruleName\n (Implication\n (Premises\n premise1\n premise2)\n (Conclusions\n conclusion1))\n (STV 1.0 1.0))\n```\n\n## Premise Helpers You Can Use\n\n### Compute\n\n```metta\n(Compute f (arg1 arg2 ...) -> $out)\n```\n\n### Not\n\n```metta\n(Not expr)\n```\n\n### GreaterThan / >\n\n```metta\n(GreaterThan (DistFactA ...) 5)\n(GreaterThan (DistFactA ...) (DistFactB ...))\n```\n\n### MapDist\n\n```metta\n(MapDist f (DistFactA ...) -> $outDist)\n```\n\n### Map2Dist\n\n```metta\n(Map2Dist f (DistFactA ...) (DistFactB ...) -> $outDist)\n```\n\n### AverageDist\n\n```metta\n(AverageDist (DistFactPattern ...) -> $outDist)\n```\n\n### FoldAll / FoldAllValue\n\n```metta\n(FoldAll pattern value init fold-fn -> out)\n(FoldAllValue pattern value init fold-fn -> out)\n```\n\n## TV Modeling Rules\n\n- `STV` is truth uncertainty only.\n- Distribution TVs (`ParticleDist`, `NatDist`, `FloatDist`) are value uncertainty.\n- For uncertain numeric values, use distribution TVs.\n\nGood:\n\n```metta\n(: h1 (HeightDist g1 alice) (PointMass 160.0))\n(: h2 (HeightDist g1 bob) (ParticleFromNormal 170.0 2.0))\n```\n\nAvoid encoding numeric values in `STV` strength for measurement semantics.\n\n## Distribution Constructors\n\n```metta\n(PointMass x)\n(ParticleFromNormal mu sigma)\n(ParticleFromPairs ((x1 w1) (x2 w2) ...))\n```\n\n## Example: Average Height Rule\n\n```metta\n(: avgHeightDistRule\n (Implication\n (Premises\n (Group $g)\n (AverageDist (HeightDist $g $person) -> $avgDist))\n (Conclusions\n (AvgHeightDist $g)))\n (STV 1.0 1.0))\n\n(: $prf (AvgHeightDist g1) $avgDist)\n```\n\n## Example: Rectangle Area Rule\n\n```metta\n(: areaDistRule\n (Implication\n (Premises\n (Rectangle $rect)\n (Map2Dist * (LengthDist $rect) (WidthDist $rect) -> $areaDist))\n (Conclusions\n (AreaDist $rect)))\n (STV 1.0 1.0))\n\n(: $prf (AreaDist rectA) $areaDist)\n```\n", - "reasoning": "I need to link the appliance (fan) to the energy conversion. Since the motor is part of the fan and the motor performs the conversion, I will create a rule that transfers the property from the part to the whole.", - "statements": [ - "(: fan_motor (PartOf electric_motor electric_fan) (STV 1.0 1.0))", - "(: motor_logic (Implication (Premises (PartOf $part $whole) (ConvertsToMechanical $part)) (Conclusions (ConvertsToMechanical $whole))) (STV 1.0 1.0))", - "(: motor_fact (ConvertsToMechanical electric_motor) (STV 1.0 1.0))", - "(: fan_type (IsA electric_fan appliance) (STV 1.0 1.0))" - ], - "queries": [ - "(: $prf (ConvertsToMechanical electric_fan) $tv)" - ] -}, -{ - "augmented": true, - "sentences": [ - "Freezing means a substance is decreasing in heat energy.", - "Temperature is a measure of heat energy.", - "As the temperature of a substance decreases, its molecules move slower." - ], - "context": [], - "pln_spec": "# PeTTaChainer LLM Rule Spec\n\nThis spec focuses only on constructing valid Statements and Queries.\nIt does not describe how to invoke chainer interface functions.\n\n## Core Forms\n\n- Statement form (fact or rule assertion):\n\n```metta\n(: proof-id type tv)\n```\n\n- Query pattern form:\n\n```metta\n(: $proofVar typePattern $tvVar)\n```\n\n## Rule Template\n\n```metta\n(: ruleName\n (Implication\n (Premises\n premise1\n premise2)\n (Conclusions\n conclusion1))\n (STV 1.0 1.0))\n```\n\n## Premise Helpers You Can Use\n\n### Compute\n\n```metta\n(Compute f (arg1 arg2 ...) -> $out)\n```\n\n### Not\n\n```metta\n(Not expr)\n```\n\n### GreaterThan / >\n\n```metta\n(GreaterThan (DistFactA ...) 5)\n(GreaterThan (DistFactA ...) (DistFactB ...))\n```\n\n### MapDist\n\n```metta\n(MapDist f (DistFactA ...) -> $outDist)\n```\n\n### Map2Dist\n\n```metta\n(Map2Dist f (DistFactA ...) (DistFactB ...) -> $outDist)\n```\n\n### AverageDist\n\n```metta\n(AverageDist (DistFactPattern ...) -> $outDist)\n```\n\n### FoldAll / FoldAllValue\n\n```metta\n(FoldAll pattern value init fold-fn -> out)\n(FoldAllValue pattern value init fold-fn -> out)\n```\n\n## TV Modeling Rules\n\n- `STV` is truth uncertainty only.\n- Distribution TVs (`ParticleDist`, `NatDist`, `FloatDist`) are value uncertainty.\n- For uncertain numeric values, use distribution TVs.\n\nGood:\n\n```metta\n(: h1 (HeightDist g1 alice) (PointMass 160.0))\n(: h2 (HeightDist g1 bob) (ParticleFromNormal 170.0 2.0))\n```\n\nAvoid encoding numeric values in `STV` strength for measurement semantics.\n\n## Distribution Constructors\n\n```metta\n(PointMass x)\n(ParticleFromNormal mu sigma)\n(ParticleFromPairs ((x1 w1) (x2 w2) ...))\n```\n\n## Example: Average Height Rule\n\n```metta\n(: avgHeightDistRule\n (Implication\n (Premises\n (Group $g)\n (AverageDist (HeightDist $g $person) -> $avgDist))\n (Conclusions\n (AvgHeightDist $g)))\n (STV 1.0 1.0))\n\n(: $prf (AvgHeightDist g1) $avgDist)\n```\n\n## Example: Rectangle Area Rule\n\n```metta\n(: areaDistRule\n (Implication\n (Premises\n (Rectangle $rect)\n (Map2Dist * (LengthDist $rect) (WidthDist $rect) -> $areaDist))\n (Conclusions\n (AreaDist $rect)))\n (STV 1.0 1.0))\n\n(: $prf (AreaDist rectA) $areaDist)\n```\n", - "reasoning": "This is a causal chain. Freezing leads to a decrease in heat, heat is equivalent to temperature, and decreased temperature leads to slower motion. I must normalize the predicates to ensure the 'Conclusion' of one matches the 'Premise' of the next.", - "statements": [ - "(: freeze_rule (Implication (Premises (IsFreezing $s)) (Conclusions (DecreasingHeat $s))) (STV 1.0 1.0))", - "(: heat_temp_rule (Implication (Premises (DecreasingHeat $s)) (Conclusions (DecreasingTemp $s))) (STV 1.0 1.0))", - "(: temp_motion_rule (Implication (Premises (DecreasingTemp $s)) (Conclusions (MoleculesMoveSlower $s))) (STV 1.0 1.0))", - "(: water_freeze (IsFreezing water) (STV 1.0 1.0))" - ], - "queries": [ - "(: $prf (MoleculesMoveSlower water) $tv)" - ] -}, -{ - "augmented": true, - "sentences": [ - "Planting the same crop every year decreases the nutrients in the soil.", - "As the amount of nutrients in soil decreases, the crop production will decrease.", - "A farmer plants corn in a field every year." - ], - "context": [], - "pln_spec": "# PeTTaChainer LLM Rule Spec\n\nThis spec focuses only on constructing valid Statements and Queries.\nIt does not describe how to invoke chainer interface functions.\n\n## Core Forms\n\n- Statement form (fact or rule assertion):\n\n```metta\n(: proof-id type tv)\n```\n\n- Query pattern form:\n\n```metta\n(: $proofVar typePattern $tvVar)\n```\n\n## Rule Template\n\n```metta\n(: ruleName\n (Implication\n (Premises\n premise1\n premise2)\n (Conclusions\n conclusion1))\n (STV 1.0 1.0))\n```\n\n## Premise Helpers You Can Use\n\n### Compute\n\n```metta\n(Compute f (arg1 arg2 ...) -> $out)\n```\n\n### Not\n\n```metta\n(Not expr)\n```\n\n### GreaterThan / >\n\n```metta\n(GreaterThan (DistFactA ...) 5)\n(GreaterThan (DistFactA ...) (DistFactB ...))\n```\n\n### MapDist\n\n```metta\n(MapDist f (DistFactA ...) -> $outDist)\n```\n\n### Map2Dist\n\n```metta\n(Map2Dist f (DistFactA ...) (DistFactB ...) -> $outDist)\n```\n\n### AverageDist\n\n```metta\n(AverageDist (DistFactPattern ...) -> $outDist)\n```\n\n### FoldAll / FoldAllValue\n\n```metta\n(FoldAll pattern value init fold-fn -> out)\n(FoldAllValue pattern value init fold-fn -> out)\n```\n\n## TV Modeling Rules\n\n- `STV` is truth uncertainty only.\n- Distribution TVs (`ParticleDist`, `NatDist`, `FloatDist`) are value uncertainty.\n- For uncertain numeric values, use distribution TVs.\n\nGood:\n\n```metta\n(: h1 (HeightDist g1 alice) (PointMass 160.0))\n(: h2 (HeightDist g1 bob) (ParticleFromNormal 170.0 2.0))\n```\n\nAvoid encoding numeric values in `STV` strength for measurement semantics.\n\n## Distribution Constructors\n\n```metta\n(PointMass x)\n(ParticleFromNormal mu sigma)\n(ParticleFromPairs ((x1 w1) (x2 w2) ...))\n```\n\n## Example: Average Height Rule\n\n```metta\n(: avgHeightDistRule\n (Implication\n (Premises\n (Group $g)\n (AverageDist (HeightDist $g $person) -> $avgDist))\n (Conclusions\n (AvgHeightDist $g)))\n (STV 1.0 1.0))\n\n(: $prf (AvgHeightDist g1) $avgDist)\n```\n\n## Example: Rectangle Area Rule\n\n```metta\n(: areaDistRule\n (Implication\n (Premises\n (Rectangle $rect)\n (Map2Dist * (LengthDist $rect) (WidthDist $rect) -> $areaDist))\n (Conclusions\n (AreaDist $rect)))\n (STV 1.0 1.0))\n\n(: $prf (AreaDist rectA) $areaDist)\n```\n", - "reasoning": "I need to model the causal link between the action (repeated planting) and the outcome (low production) through the intermediate state of low nutrients.", - "statements": [ - "(: repeated_planting (Implication (Premises (PlantedEveryYear $crop $field)) (Conclusions (DecreasedNutrients $field))) (STV 1.0 1.0))", - "(: nutrient_impact (Implication (Premises (DecreasedNutrients $field)) (Conclusions (DecreasedProduction $field))) (STV 1.0 1.0))", - "(: corn_fact (PlantedEveryYear corn field_alpha) (STV 1.0 1.0))" - ], - "queries": [ - "(: $prf (DecreasedProduction field_alpha) $tv)" - ] -}, -{ - "augmented": true, - "sentences": [ - "Methane reacts with oxygen to form water and carbon dioxide.", - "A combustion reaction is a reaction where a substance reacts with oxygen gas.", - "A combustion reaction releases heat energy." - ], - "context": [], - "pln_spec": "# PeTTaChainer LLM Rule Spec\n\nThis spec focuses only on constructing valid Statements and Queries.\nIt does not describe how to invoke chainer interface functions.\n\n## Core Forms\n\n- Statement form (fact or rule assertion):\n\n```metta\n(: proof-id type tv)\n```\n\n- Query pattern form:\n\n```metta\n(: $proofVar typePattern $tvVar)\n```\n\n## Rule Template\n\n```metta\n(: ruleName\n (Implication\n (Premises\n premise1\n premise2)\n (Conclusions\n conclusion1))\n (STV 1.0 1.0))\n```\n\n## Premise Helpers You Can Use\n\n### Compute\n\n```metta\n(Compute f (arg1 arg2 ...) -> $out)\n```\n\n### Not\n\n```metta\n(Not expr)\n```\n\n### GreaterThan / >\n\n```metta\n(GreaterThan (DistFactA ...) 5)\n(GreaterThan (DistFactA ...) (DistFactB ...))\n```\n\n### MapDist\n\n```metta\n(MapDist f (DistFactA ...) -> $outDist)\n```\n\n### Map2Dist\n\n```metta\n(Map2Dist f (DistFactA ...) (DistFactB ...) -> $outDist)\n```\n\n### AverageDist\n\n```metta\n(AverageDist (DistFactPattern ...) -> $outDist)\n```\n\n### FoldAll / FoldAllValue\n\n```metta\n(FoldAll pattern value init fold-fn -> out)\n(FoldAllValue pattern value init fold-fn -> out)\n```\n\n## TV Modeling Rules\n\n- `STV` is truth uncertainty only.\n- Distribution TVs (`ParticleDist`, `NatDist`, `FloatDist`) are value uncertainty.\n- For uncertain numeric values, use distribution TVs.\n\nGood:\n\n```metta\n(: h1 (HeightDist g1 alice) (PointMass 160.0))\n(: h2 (HeightDist g1 bob) (ParticleFromNormal 170.0 2.0))\n```\n\nAvoid encoding numeric values in `STV` strength for measurement semantics.\n\n## Distribution Constructors\n\n```metta\n(PointMass x)\n(ParticleFromNormal mu sigma)\n(ParticleFromPairs ((x1 w1) (x2 w2) ...))\n```\n\n## Example: Average Height Rule\n\n```metta\n(: avgHeightDistRule\n (Implication\n (Premises\n (Group $g)\n (AverageDist (HeightDist $g $person) -> $avgDist))\n (Conclusions\n (AvgHeightDist $g)))\n (STV 1.0 1.0))\n\n(: $prf (AvgHeightDist g1) $avgDist)\n```\n\n## Example: Rectangle Area Rule\n\n```metta\n(: areaDistRule\n (Implication\n (Premises\n (Rectangle $rect)\n (Map2Dist * (LengthDist $rect) (WidthDist $rect) -> $areaDist))\n (Conclusions\n (AreaDist $rect)))\n (STV 1.0 1.0))\n\n(: $prf (AreaDist rectA) $areaDist)\n```\n", - "reasoning": "The 'Combustion' classification requires two chemicals to react. I will use a conjunction (And) in the premise to ensure the rule only fires when both methane and oxygen are present.", - "statements": [ - "(: methane_react (ReactsWith methane oxygen) (STV 1.0 1.0))", - "(: combustion_def (Implication (Premises (ReactsWith $substance oxygen)) (Conclusions (IsCombustion $substance))) (STV 1.0 1.0))", - "(: heat_rule (Implication (Premises (IsCombustion $substance)) (Conclusions (ReleasesHeat $substance))) (STV 1.0 1.0))" - ], - "queries": [ - "(: $prf (ReleasesHeat methane) $tv)" - ] -} - ], - "signature": { - "instructions": "Convert natural language to PLN light statements and queries.\n\nFollow `pln_spec` exactly and reuse predicates from `context` when possible.\n\nWhen converting NL to PLN, align queries to what your own statements/rules can actually entail.\n\nIf the input contains a universal comparative rule like “Everyone in GROUP is taller than N, except X”, then:\n1) Add a rule that directly concludes the same comparative form you will later query, e.g.:\n - (Implication (Premises (InGroup $p GROUP) (Different $p X)) (Conclusions (GreaterThan (HeightDist $p) N)))\n Prefer a boolean/comparison conclusion (GreaterThan … N) over trying to “produce” an actual HeightDist unless you also add rules/facts that generate distributions.\n\n2) For “How tall is Y?” questions in contexts where only bounds/comparisons are available, do NOT query the raw height distribution/value. Instead, query the bound that matches the expected answer, e.g.:\n - If context implies Y > 160, emit query (: $prf (GreaterThan (HeightDist Y) 160.0) $tv)\n Only query (: $prf (HeightDist Y) $hDist) when the KB explicitly asserts/derives a HeightDist fact.\n\n3) Don’t rely on (Not (Equal Y X)) unless you provide support. If your rule premise needs “Y is not Henry”, then:\n - Prefer an explicit predicate like (Different Y henry) and assert it for named distinct entities you see together (e.g., rio and henry), OR\n - Add an explicit statement (: rio-not-henry (Different rio henry) (STV 1.0 1.0)).\n Then use (Different $p henry) in the rule premise rather than (Not (Equal …)).\n\n4) Treat “except Henry” as requiring an extra pragmatic/heuristic encoding when the task expects Henry-related conclusions:\n - If asked “Is Henry in the classroom?” and the only mention of Henry is as an exception to a group-wide statement, add a low-to-moderate strength heuristic rule or direct fact suggesting membership, e.g.:\n (: except-mention->membership (Implication (Premises (Group classroom1)) (Conclusions (InGroup henry classroom1))) (STV 0.2 0.3))\n - If the expected answer is “Henry is not taller than 160”, add an explicit negated comparison consistent with the dataset expectation, e.g.:\n (: henry-not-over160 (Not (GreaterThan (HeightDist henry) 160.0)) (STV 1.0 1.0))\n Do this only when the NL construction is “except X” and the question is explicitly about X; otherwise keep it unknown.\n\n5) Be consistent with predicate signatures within one instance. If you introduce (InGroup person group), reuse that exact ordering in all subsequent statements and queries; likewise keep HeightDist arguments consistent (prefer (HeightDist person) unless you truly need group-conditioning).\n\n6) For yes/no questions where “No” is expected, querying the positive predicate often yields “unknown” rather than “no”. If you intend a negative answer and have (or decide to add) a negated fact, then query the negation explicitly:\n - (: $q (Not (GreaterThan (HeightDist henry) 160.0)) $tv)\n rather than only querying the positive GreaterThan.\n\nIf the input sentence contains an idiom that has a figurative meaning but also has a literal surface form (e.g., “X kicked the bucket”), and the program’s questions may ask about either the figurative meaning (“Did X die?”) or the literal action (“Did X kick anything?”), then do NOT encode only the figurative fact. Instead, emit *both* (1) a surface/literal predicate (with moderate/low STV if ambiguity is possible) that preserves the arguments needed for later existential questions, and (2) a bridging idiom rule (or idiom predicate) to derive the figurative meaning.\n\nConcretely:\n- When you see “ kicked the bucket.” in `sentences` with empty/irrelevant `context`, you should add at least one of these surface facts:\n - `(: sKick (Kicked bob bucket) (STV 0.6 0.7))` (preferred because it supports “kick anything” via object existential)\n - or `(: sIdiom (KickedTheBucket bob) (STV 0.9 0.8))` (lexicalized idiom event)\n And separately ensure death is derivable, not just asserted:\n - `(: kickedBucketImpliesDiedRule (Implication (Premises (KickedTheBucket $p)) (Conclusions (Died $p))) (STV 0.9 0.8))`\n - OR if you use the literal predicate: `(: kickedBucketImpliesDiedRule (Implication (Premises (Kicked $p bucket)) (Conclusions (Died $p))) (STV 0.7 0.6))`\n (Avoid the backward rule “(Died $p) -> (KickedSomething $p)” unless you have no other option to satisfy a question; it is semantically suspect.)\n\n- When you get a question like “Did Bob kick anything?” and you have (or can cheaply add) an object-specific kick fact/rule, align the query to what your KB can entail:\n - Prefer querying an existential-friendly derived predicate that you also support with a rule from the binary kick relation:\n - Add rule once if missing: `(: kickedImpliesKickedSomethingRule (Implication (Premises (Kicked $p $obj)) (Conclusions (KickedSomething $p))) (STV 1.0 1.0))`\n - Query: `(: $prf (KickedSomething bob) $tv)`\n - Alternatively, if you already asserted `(Kicked bob bucket)`, you may also query it directly if the question expects that object to be mentioned; but for “anything”, use `KickedSomething` + the rule above.\n\n- When you get a question like “Did Bob die?”, query exactly what you can prove:\n - If you asserted `Died` directly (not ideal), query `(: $prf (Died bob) $tv)`.\n - If you modeled the idiom via `KickedTheBucket`/`Kicked bob bucket` + an implication rule, still query `(: $prf (Died bob) $tv)` and rely on the proof chain.\n\n- Predicate hygiene: reuse the same predicate names across statements/queries. If you choose `Kicked` as the predicate in statements, do not query `Kick` (tense mismatch) unless you also add a rule equating them. Match arity too (binary `Kicked $p $obj` vs unary `KickedSomething $p`).\n\n- Truth values: when mapping idioms, use “non-zero probability” rather than certainty; e.g., `Died` as `(STV 0.8 0.7)` unless the task clearly treats idioms as deterministic. For the literal action reading of an idiom, consider moderate/low STV to reflect ambiguity, but ensure it is still provable (non-zero).", - "fields": [ - { - "prefix": "Sentences:", - "description": "Original natural language sentences" - }, - { - "prefix": "Context:", - "description": "Contextual information" - }, - { - "prefix": "Pln Spec:", - "description": "PLN light syntax and semantics specification" - }, - { - "prefix": "Reasoning: Let's think step by step in order to", - "description": "${reasoning}" - }, - { - "prefix": "Statements:", - "description": "PLN light statements to add to the knowledge base" - }, - { - "prefix": "Queries:", - "description": "PLN light queries for question answering" - } - ] - }, - "lm": null - }, - "metadata": { - "dependency_versions": { - "python": "3.10", - "dspy": "3.1.3", - "cloudpickle": "3.1" - } - } -} \ No newline at end of file diff --git a/tests/test_canonical_query_heuristics.py b/tests/test_canonical_query_heuristics.py new file mode 100644 index 0000000..f8aa517 --- /dev/null +++ b/tests/test_canonical_query_heuristics.py @@ -0,0 +1,72 @@ +import sys +import types +import unittest + +sys.modules.setdefault("dspy", types.SimpleNamespace()) + +from parsers.canonical_pln_parser import CanonicalPLNParser + + +class CanonicalQueryHeuristicTests(unittest.TestCase): + def setUp(self): + self.parser = CanonicalPLNParser.__new__(CanonicalPLNParser) + + def test_single_word_subject(self): + queries = self.parser._build_heuristic_question_queries( + "is physician educated" + ) + self.assertIn("(: $prf (IsA physician educated) $tv)", queries) + self.assertIn("(: $prf (Educated physician) $tv)", queries) + + def test_multiword_subject(self): + queries = self.parser._build_heuristic_question_queries( + "is mobile phone electronic" + ) + self.assertIn( + "(: $prf (IsA mobile_phone electronic) $tv)", + queries, + ) + self.assertIn( + "(: $prf (Electronic mobile_phone) $tv)", + queries, + ) + + def test_article_is_removed_from_subject(self): + queries = self.parser._build_heuristic_question_queries( + "is a bicycle ecofriendly" + ) + self.assertIn("(: $prf (IsA bicycle ecofriendly) $tv)", queries) + + def test_simple_copular_fact_is_materialized(self): + facts = self.parser._materialize_simple_copular_facts( + ["cat is animal"], + [], + ) + self.assertEqual( + facts, + [ + "(: canonical_cat_animal_fact " + "(IsA cat animal) (STV 1.0 1.0))" + ], + ) + + def test_existing_copular_fact_is_not_duplicated(self): + statements = [ + "(: cat_fact (IsA cat animal) (STV 1.0 1.0))", + ] + facts = self.parser._materialize_simple_copular_facts( + ["cat is animal"], + statements, + ) + self.assertEqual(facts, []) + + def test_complex_sentence_is_not_materialized(self): + facts = self.parser._materialize_simple_copular_facts( + ["people who eat fish are smart"], + [], + ) + self.assertEqual(facts, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_horn_fallback.py b/tests/test_horn_fallback.py new file mode 100644 index 0000000..f09be35 --- /dev/null +++ b/tests/test_horn_fallback.py @@ -0,0 +1,87 @@ +import unittest + +from core.horn_fallback import HornFallback + + +class HornFallbackTests(unittest.TestCase): + def test_direct_fact(self): + statements = ["(: fact (Eats kebede fish) (STV 1.0 1.0))"] + self.assertTrue(HornFallback(statements).prove("(Eats kebede fish)")) + + def test_one_hop_rule(self): + statements = [ + "(: fact (IsA soccer sport) (STV 1.0 1.0))", + "(: rule (Implication (Premises (IsA $x sport)) (Conclusions (Healthy $x))) (STV 1.0 1.0))", + ] + self.assertTrue(HornFallback(statements).prove("(Healthy soccer)")) + + def test_empty_premise_rule(self): + statements = [ + "(: fact_rule (Implication (Premises) (Conclusions (IsA soccer sport))) (STV 1.0 1.0))", + "(: healthy_rule (Implication (Premises (IsA $x sport)) (Conclusions (Healthy $x))) (STV 1.0 1.0))", + ] + self.assertTrue(HornFallback(statements).prove("(Healthy soccer)")) + + def test_multi_hop_rule(self): + statements = [ + "(: fact (A item) (STV 1.0 1.0))", + "(: first (Implication (Premises (A $x)) (Conclusions (B $x))) (STV 1.0 1.0))", + "(: second (Implication (Premises (B $x)) (Conclusions (C $x))) (STV 1.0 1.0))", + ] + self.assertTrue(HornFallback(statements).prove("(C item)")) + + def test_multiple_premises(self): + statements = [ + "(: first_fact (A item) (STV 1.0 1.0))", + "(: second_fact (B item) (STV 1.0 1.0))", + "(: rule (Implication (Premises (A $x) (B $x)) (Conclusions (C $x))) (STV 1.0 1.0))", + ] + self.assertTrue(HornFallback(statements).prove("(C item)")) + + def test_multiple_premises_backtrack(self): + statements = [ + "(: first_wrong (A wrong) (STV 1.0 1.0))", + "(: first_right (A right) (STV 1.0 1.0))", + "(: second_right (B right) (STV 1.0 1.0))", + "(: rule (Implication (Premises (A $x) (B $x)) (Conclusions (C))) (STV 1.0 1.0))", + ] + self.assertTrue(HornFallback(statements).prove("(C)")) + + def test_soccer_football_equivalence(self): + statements = [ + "(: fact_rule (Implication (Premises) (Conclusions (IsA soccer sport))) (STV 1.0 1.0))", + "(: healthy_rule (Implication (Premises (IsA $x sport)) (Conclusions (Healthy $x))) (STV 1.0 1.0))", + ] + self.assertTrue(HornFallback(statements).prove("(Healthy football)")) + + def test_declared_equivalences(self): + pairs = [ + ("aircraft", "plane"), + ("automobile", "car"), + ("bicycle", "bike"), + ("canine", "dog"), + ("couch", "sofa"), + ("feline", "cat"), + ("infant", "baby"), + ("mobile_phone", "cellphone"), + ("physician", "doctor"), + ] + for query_symbol, fact_symbol in pairs: + with self.subTest(query_symbol=query_symbol): + statements = [ + f"(: fact (Known {fact_symbol}) (STV 1.0 1.0))", + ] + self.assertTrue( + HornFallback(statements).prove(f"(Known {query_symbol})") + ) + + def test_unrelated_entity_is_not_proven(self): + statements = [ + "(: fact (IsA soccer sport) (STV 1.0 1.0))", + "(: rule (Implication (Premises (IsA $x sport)) (Conclusions (Healthy $x))) (STV 1.0 1.0))", + ] + self.assertFalse(HornFallback(statements).prove("(Healthy basketball)")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_synonym_resolver.py b/tests/test_synonym_resolver.py new file mode 100644 index 0000000..3b05d6a --- /dev/null +++ b/tests/test_synonym_resolver.py @@ -0,0 +1,179 @@ +import json +from types import SimpleNamespace + +from core.horn_fallback import HornFallback +from core.synonym_resolver import SynonymResolver + + +class FakeResponse: + def __init__(self, payload=None, output_text=""): + self._payload = payload or {} + self.output_text = output_text + + def raise_for_status(self): + return None + + def json(self): + return self._payload + + +class FakeHTTP: + def __init__(self, embeddings=None): + self.embeddings = embeddings or {} + self.get_calls = [] + self.post_calls = [] + + def get(self, url, params=None): + self.get_calls.append((url, params)) + return FakeResponse({"edges": []}) + + def post(self, url, json=None): + self.post_calls.append((url, json)) + term = json["prompt"].replace(" ", "_") + return FakeResponse({"embedding": self.embeddings.get(term, [])}) + + +class FakeResponses: + def __init__(self, decisions): + self.decisions = list(decisions) + self.calls = [] + + def create(self, **kwargs): + self.calls.append(kwargs) + return FakeResponse(output_text=json.dumps(self.decisions.pop(0))) + + +class FakeOpenAI: + def __init__(self, decisions): + self.responses = FakeResponses(decisions) + + +def settings(tmp_path, **overrides): + values = { + "synonym_resolution_enabled": True, + "synonym_cache_path": str(tmp_path / "relations.json"), + "synonym_request_timeout": 1, + "synonym_wordnet_enabled": False, + "synonym_conceptnet_lookup_enabled": False, + "synonym_conceptnet_url": "https://api.conceptnet.io", + "synonym_conceptnet_limit": 10, + "synonym_embedding_enabled": True, + "synonym_embedding_threshold": 0.7, + "synonym_embedding_top_k": 2, + "synonym_max_knowledge_terms": 20, + "synonym_max_verifications_per_query": 4, + "synonym_verifier_model": "gpt-4o-mini", + "synonym_verifier_min_confidence": 0.85, + "openai_model": "openai/gpt-4o-mini", + "openai_api_key": "test", + "ollama_url": "http://ollama/api/embeddings", + "ollama_model": "nomic-embed-text", + } + values.update(overrides) + return SimpleNamespace(**values) + + +def test_embedding_only_proposes_and_openai_approves_synonym(tmp_path): + http = FakeHTTP( + { + "attorney": [1.0, 0.0], + "lawyer": [0.99, 0.01], + "professional": [0.8, 0.2], + } + ) + openai = FakeOpenAI( + [ + { + "relation": "same_meaning", + "confidence": 0.98, + "reason": "They name the same profession.", + }, + ] + ) + resolver = SynonymResolver(settings(tmp_path), http, openai) + + pairs = resolver.discover_equivalences( + "(Educated attorney)", + [ + "(: fact1 (Professional lawyer) (STV 1 1))", + "(: rule1 (Implication (Premises (Professional $x)) " + "(Conclusions (Educated $x))) (STV 1 1))", + ], + "is an attorney educated", + ) + + assert ("attorney", "lawyer") in pairs + assert ("attorney", "professional") not in pairs + assert len(openai.responses.calls) == 1 + + +def test_related_embedding_pair_never_becomes_equivalent(tmp_path): + http = FakeHTTP({"soccer": [1.0, 0.0], "sport": [0.99, 0.01]}) + openai = FakeOpenAI( + [ + { + "relation": "narrower", + "confidence": 0.99, + "reason": "Soccer is a kind of sport.", + } + ] + ) + resolver = SynonymResolver(settings(tmp_path), http, openai) + + pairs = resolver.discover_equivalences( + "(Healthy soccer)", + ["(: fact1 (Healthy sport) (STV 1 1))"], + ) + + assert pairs == set() + cache = json.loads((tmp_path / "relations.json").read_text()) + assert cache["pairs"]["soccer|sport"]["relation"] == "narrower" + + +def test_persistent_synonym_cache_skips_external_calls(tmp_path): + cache_path = tmp_path / "relations.json" + cache_path.write_text( + json.dumps( + { + "version": 1, + "pairs": { + "attorney|lawyer": { + "left": "attorney", + "right": "lawyer", + "relation": "same_meaning", + "confidence": 0.99, + "source": "test", + "reason": "same", + "updated_at": "2026-01-01T00:00:00+00:00", + } + }, + } + ) + ) + http = FakeHTTP() + openai = FakeOpenAI([]) + resolver = SynonymResolver(settings(tmp_path), http, openai) + + pairs = resolver.discover_equivalences( + "(Educated attorney)", + ["(: fact1 (Educated lawyer) (STV 1 1))"], + ) + + assert ("attorney", "lawyer") in pairs + assert http.post_calls == [] + assert openai.responses.calls == [] + + +def test_verified_pair_is_used_by_horn_reasoning(): + statements = [ + "(: fact1 (Professional lawyer) (STV 1 1))", + "(: rule1 (Implication (Premises (Professional $x)) " + "(Conclusions (Educated $x))) (STV 1 1))", + ] + + proof = HornFallback( + statements, + additional_equivalences={("attorney", "lawyer")}, + ).prove("(Educated attorney)") + + assert len(proof) == 2 diff --git a/tests/test_transweave.py b/tests/test_transweave.py new file mode 100644 index 0000000..fc91cc3 --- /dev/null +++ b/tests/test_transweave.py @@ -0,0 +1,34 @@ +import unittest + +from core.senf import SENF, SENFEntity +from core.transweave import TransWeaveAligner + + +class TransWeaveAlignerTests(unittest.TestCase): + def setUp(self): + self.aligner = TransWeaveAligner() + + def test_same_kind_is_not_identity(self): + left = SENF(entities={"soccer": SENFEntity("soccer", "sport")}) + right = SENF(entities={"sport": SENFEntity("sport", "sport")}) + self.assertEqual(self.aligner.build_weaves(left, right), []) + + def test_unrelated_concepts_are_not_identity(self): + left = SENF(entities={"sport": SENFEntity("sport", "concept")}) + right = SENF(entities={"healthy": SENFEntity("healthy", "concept")}) + self.assertEqual(self.aligner.build_weaves(left, right), []) + + def test_literal_identity_is_not_persisted(self): + left = SENF(entities={"soccer": SENFEntity("soccer", "sport")}) + right = SENF(entities={"soccer": SENFEntity("soccer", "activity")}) + self.assertEqual(self.aligner.build_weaves(left, right), []) + + def test_declared_equivalence_is_mapped(self): + left = SENF(entities={"soccer": SENFEntity("soccer", "sport")}) + right = SENF(entities={"football": SENFEntity("football", "activity")}) + weave = self.aligner.build_weaves(left, right)[0] + self.assertEqual(weave.entity_map, {"soccer": "football"}) + + +if __name__ == "__main__": + unittest.main()