Compare commits

..
Author SHA1 Message Date
Zohaib Hassnain 5ffb212a8f Merge branch 'main' into feat/claude-wrapper 2026-08-29 22:15:41 +05:00
Zohaib Hassnain ca7f743dab fix(llms): address Qodo review findings on Anthropic wrapper 2026-08-29 22:15:19 +05:00
Zohaib Hassnain 5cf59fdd88 feat(llms): add first class Anthropic provider wrapper 2026-08-29 22:01:16 +05:00
Mohd Kaif 0384a8de30 Merge pull request #1077 from cxzg007/fix/rete-pattern-matching
fix(reasoning): implement RETE alpha/beta matching with Token model (#300)
2026-08-29 21:57:15 +05:30
KaifAhmad1 f3d5932c24 Merge remote-tracking branch 'origin/main' into fix/rete-pattern-matching
Reconciles this PR's Token-based alpha/beta matching (#300) with the
rule-actions/provenance layer merged separately in #1096. That PR built
bind_reasoner()/execute_matches() action-firing/_executed_activations/
reset_action_history() on top of the still-broken always-True stubs
(via an interim _bindings_for_rule() regex re-extraction), so main and
this branch touched the same propagation code with incompatible shapes.

Kept this branch's Token(facts, bindings) model for alpha/beta
propagation (the actual fix for #300) and layered main's action/
provenance plumbing on top of it, sourcing Match.bindings directly from
Token.bindings instead of re-deriving them with _bindings_for_rule(),
which is now redundant and removed. Also fixes a 2-tuple/3-tuple
unpacking break in test_matches_reasoner_match_rule caused by
Reasoner._match_rule()'s return shape changing upstream, and drops an
unrelated encoding-only .gitignore diff.

Verified: tests/reasoning/ (106 tests) and flake8 --max-line-length=88
both clean on the merged tree.
2026-08-29 21:50:04 +05:30
Mohd Kaif a5aac7e22a Merge pull request #1232 from dex0shubham/test/1167-fastapi-collection-guards
test: guard fastapi-dependent modules so collection succeeds without the explorer extra
2026-08-29 21:20:20 +05:30
dex0shubham 47446ebdde test(explorer): guard the deterministic-rendering e2e module on fastapi
This module landed after the branch was opened and imports
semantica.explorer.app at module scope, so it reproduced the same
collection error on a clean [dev] install.
2026-08-29 13:31:00 +01:00
dex0shubham c8a591e89e test: scope the security-regression guard to the SPARQL class, guard the new decision-route test
Addresses review feedback on #1232.
2026-08-29 13:27:42 +01:00
dex0shubham ab86127e4e test: guard fastapi-dependent modules so collection succeeds without the explorer extra
Closes #1167
2026-08-29 13:27:42 +01:00
Mohd Kaif 30592b1285 Merge pull request #1245 from HsienW/fix/sliding-window-chunker-non-termination
fix(split): validate sliding window progress
2026-08-29 15:56:29 +05:30
Mohd Kaif aceb69a5bc Merge branch 'main' into fix/sliding-window-chunker-non-termination 2026-08-29 15:44:18 +05:30
Mohd Kaif e13c953bd8 Merge pull request #1249 from semantica-agi/fix/codeql-action-pin
ci: resync github/codeql-action pin to current v4
2026-08-29 14:12:47 +05:30
yzxcj797andSameer Kadam 85d6ccd0a5 fix(memory): find_by_entity returns all matches by default (#1024)
* fix(memory): find_by_entity returns all matches by default (limit=None, not 10)

* Address review: move find_by_entity tests to the AgentMemory area

The regression tests lived in tests/test_seed_manager.py, mixing unrelated
domains. Moved to tests/context/test_agent_memory_find_by_entity.py with a
shared fixture; the unbounded default itself is unchanged and deliberate —
it IS the fix (#1018): an erasure workflow computing what references an
entity cannot paginate, so silently truncating at 10 left live references
behind. Callers that want a page pass an explicit limit.

---------

Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
2026-08-29 14:09:40 +05:30
hsien wei dfd668c206 fix(split): validate sliding window progress
- Reject non-positive stride values before chunking and validate temporary overlap overrides before mutating chunker state.

- Restore the original overlap and custom stride with `try/finally` so state remains unchanged after both successful and failed `chunk_with_overlap()` calls.

- Add regression coverage for invalid stride and overlap values, valid boundary cases, and state restoration.
2026-08-28 05:03:45 +08:00
江俊杰 e74d0a274d Merge remote-tracking branch 'upstream/main' into fix/rete-pattern-matching
# Conflicts:
#	CHANGELOG.md
2026-08-25 10:04:54 +08:00
江俊杰 b570794515 perf(reasoning): precompile alpha node condition regex
unify_condition() rebuilt a regex (re.split + concat + re.match) for
every fact tested against every alpha node. Since RETE evaluates many
facts across many alpha nodes, this repeated construction added
significant overhead.

- Extract regex construction into _build_condition_regex() (reused by
  unify_condition and AlphaNode).
- AlphaNode.__init__ now compiles its condition once (no initial
  bindings at alpha time) into self._compiled and reuses it per fact.
- On compile failure, log a WARNING and treat the node as non-matching,
  consistent with the earlier observability fix.
- Add tests for the compiled path and the compile-failure fallback.

Refs #300
2026-08-19 10:24:24 +08:00
江俊杰 a94cec3b36 fix(reasoning): log unify_condition regex errors for observability
Previously unify_condition() silently caught re.error and returned None
with no log context, unlike Reasoner._match_pattern() which logs the
pattern/regex/fact on failure. This made malformed conditions hard to
diagnose in the RETE engine.

- Add a module-level logger ("semantica.rete_engine") for the standalone
  unify_condition() helper.
- On re.error, log a WARNING including the condition pattern, compiled
  regex, and fact string before returning None.
- Also catch unexpected exceptions (noqa BLE001) with the same context,
  mirroring Reasoner._match_pattern behaviour.
- Add tests asserting both error paths log a warning and return None.

Refs #300
2026-08-19 10:24:24 +08:00
江俊杰 f9b1295d14 fix(reasoning): implement RETE alpha/beta matching with Token model (#300)
AlphaNode._matches and BetaNode._can_join were placeholder stubs that
always returned True, so the Rete network fired every rule for every
fact. Add a regex-based unify_condition (reusing Reasoner._match_pattern's
approach) that binds ?vars via named groups and enforces repeated-variable
and cross-condition binding consistency.

Rework propagation around a Token model (facts + bindings) instead of bare
facts: AlphaNode emits single-fact tokens, and BetaNode.join merges left/
right tokens, concatenating facts in condition order and returning a merged
token only when shared variables agree. This fixes a P1 chained-join defect
where rules with three or more conditions lost bindings and accumulated
wrong facts at the third join, and a conflicting third condition could
spuriously fire. Beta nodes now keep both left/right token memories and
join each new token against every token on the opposite side.

Also fix an adjacent bug where beta nodes were never wired into their
inputs' children, blocking propagation. Adds tests/reasoning/test_rete_engine.py
including a TestThreeConditionChain suite (valid match, third-level conflict
suppression, insertion-order independence, complete in-order Match.facts,
multiple left tokens joining one right fact, parity against
Reasoner._match_rule, and reset clearing all token memory).
2026-08-19 10:24:24 +08:00
24 changed files with 1100 additions and 172 deletions
+9
View File
@@ -213,6 +213,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **RETE engine matched every fact against every rule — `AlphaNode._matches()` and `BetaNode._can_join()` were placeholder stubs that always returned `True`** (closes #300)
- `semantica/reasoning/rete_engine.py` shipped a Rete network whose per-condition alpha test and cross-condition beta join were both `return True` stubs, so `match_patterns()` fired every rule for every fact regardless of predicate, arity, or shared-variable consistency
- New module-level `unify_condition()` reuses the regex-based approach from `Reasoner._match_pattern()`: a condition pattern like `Person(?x)` / `Parent(?x, ?y)` is compiled against a fact's `predicate(arg, ...)` string, `?var` becomes a named capture group, and a variable seen twice within one condition (e.g. `Loves(?x, ?x)`) becomes a backreference, so it only unifies when both positions hold the same value. Returns the bindings dict or `None`
- Reworked propagation to carry partial-match **tokens** instead of bare facts: a new `Token` dataclass bundles the accumulated `facts` with the consistent `bindings`. `AlphaNode` emits a single-fact token per match; `BetaNode.join()` merges a left token with a right token, concatenating their facts in condition order and returning the merged token only when shared variables agree (conflicting values → `None`, no join). Terminal activations carry the full fact list and accumulated bindings through to the emitted match
- This fixes a P1 chained-join defect: rules with three or more conditions (e.g. `Person(?x)`, `Parent(?x, ?y)`, `Located(?y, ?z)`) previously lost bindings and accumulated wrong facts at the third join, and a conflicting third condition could spuriously fire. Beta nodes now keep both `left_tokens` and `right_tokens` memories and join each new token against every token on the opposite side, so deep chains stay binding-consistent and third-level conflicts are correctly suppressed
- Fixed an adjacent network-topology bug surfaced by the above: newly created beta nodes were never appended to their input nodes' `children`, so tokens could not propagate; propagation was reworked to support chained joins and to thread bindings end-to-end
- Reconciled with the rule-actions/provenance layer (#1096) merged after this fix was opened: `execute_matches()` still dedupes and fires `Rule.actions`/legacy `handler` through a bound `Reasoner` via `_make_activation_key`, now sourced from the Token model's own `bindings` instead of the interim `_bindings_for_rule()` regex re-extraction, which is removed as redundant
- New `tests/reasoning/test_rete_engine.py`: `unify_condition` unit cases (single/multi variable, literal args, predicate mismatch, repeated-variable equality), alpha match/reject, beta consistent-join vs conflict-reject, end-to-end rules (single-condition fires only the matching fact; multi-condition join fires only on consistent bindings), and a `TestThreeConditionChain` suite (valid three-condition match, third-level conflict suppression, insertion-order independence, `Match.facts` complete and in condition order, multiple left tokens joining one right fact, parity against `Reasoner._match_rule()`, and `reset()` clearing all token memory)
- **KG provenance tests asserted on generated ID strings instead of stored records, and `kg_provenance.py` was missed by the `utcnow` sweep** (closes #946) by @pravit-amp
- The KG workflow and integration suites checked that a tracker call returned an ID matching a prefix (`assert cent_id.startswith("centrality_")`) without ever reading the record back, so an ID generator that returned a well-formed string and wrote nothing would have passed. Worse, some of those calls named tracker methods that do not exist anywhere in `semantica/` (`track_layer_analysis`, `track_centrality_score`), so the assertions were satisfied with no real interaction behind them
- Those tests now read provenance back through `get_provenance()` and assert on algorithm metadata, and call the methods that actually persist records. Verified by mutation rather than by a green run alone: neutering the manager's storage write (`self.storage.store(...)` → no-op) fails 10 tests
+55
View File
@@ -28,6 +28,7 @@ The `semantica.llms` module provides a unified interface for connecting to Large
## When To Use / When Not To Use
**Use LLM integrations for:**
- Text generation, summarization, and question-answering tasks
- Complex reasoning that requires natural language understanding
- Structured data extraction from unstructured text
@@ -35,6 +36,7 @@ The `semantica.llms` module provides a unified interface for connecting to Large
- Tasks where context, ambiguity, or domain knowledge matter
**Deterministic tools may be better for:**
- Pattern matching that regular expressions can handle
- Simple rule-based classification with clear criteria
- Mathematical calculations or statistical analysis
@@ -42,6 +44,7 @@ The `semantica.llms` module provides a unified interface for connecting to Large
- Data transformations with known logic
**A full LLM may be unnecessary for:**
- Simple keyword search or exact string matching
- Deterministic workflows with predefined decision trees
- High-frequency, low-latency operations where inference overhead matters
@@ -143,6 +146,58 @@ risk_data = oai.generate_structured(
The default model `gpt-3.5-turbo` is fine for classification and light extraction. Switch to `gpt-4o` for complex multi-step regulatory reasoning or document understanding.
## Anthropic — Complex Reasoning and Structured Extraction
**Anthropic** provides the Claude model family, built with an emphasis on careful, instruction-following behavior and strong performance on multi-step reasoning, long-document analysis, and code-related tasks. Claude models tend to be more cautious about ambiguous instructions than other providers. That matters when the cost of a confidently wrong answer is high.
The `Anthropic` provider wraps the Claude API. Reach for it when the task involves reasoning through several dependent steps (not just single-turn extraction), when you're processing long source documents that need to stay in context, or when you need schema-validated structured output rather than best-effort JSON.
Install with `pip install "semantica[llm-anthropic]"` (or just `pip install anthropic`) before using this provider.
```python
from semantica.llms import Anthropic
claude = Anthropic(model="claude-3-sonnet-20240229", api_key="YOUR_ANTHROPIC_KEY")
# api_key falls back to the ANTHROPIC_API_KEY environment variable
# is_available() only confirms a client was constructed from some key.
# It does not validate the key or check network reachability - an
# invalid or expired key still passes this check and fails at generate().
if not claude.is_available():
raise RuntimeError("Anthropic provider not configured - set ANTHROPIC_API_KEY")
# Plain generation - multi-step reasoning over a contract clause
verdict = claude.generate(
"A vendor contract has a 30-day termination-for-convenience clause "
"but a 90-day data-return obligation that survives termination. "
"If the customer terminates on day 1, when must vendor-held data "
"be returned? Answer with the date basis only.",
temperature=0.1,
)
print(verdict)
# "Day 120 from termination notice. The 90-day return period runs from
# the termination date (day 30), not from the notice date."
# Structured, schema-validated output
from pydantic import BaseModel
class ContractRisk(BaseModel):
clause: str
risk_level: str
days_to_deadline: int
risk = claude.generate_typed(
"Extract the termination clause risk from: vendor contract, "
"30-day termination for convenience, 90-day post-termination "
"data return obligation.",
schema=ContractRisk,
)
print(risk.risk_level, risk.days_to_deadline)
# "medium" 90
```
Model selection follows the same tier structure as the other providers: a Haiku model for high-volume classification where cost matters more than depth, a Sonnet model as the default for most extraction and reasoning tasks, an Opus model when a task genuinely needs the deepest reasoning available and latency/cost are secondary. Check Anthropic's docs for the current model identifiers, since they're versioned and change over time.
## LiteLLM — One Interface, 100+ Providers
**LiteLLM** is a universal adapter that provides a single interface to over 100 different LLM providers, including Anthropic Claude, Azure OpenAI, AWS Bedrock, Google Vertex AI, and local Ollama instances. It acts as a translation layer, converting your unified API calls into provider-specific requests, enabling easy switching between providers without code changes.
+10 -4
View File
@@ -1286,13 +1286,19 @@ class AgentMemory:
"""
return self.retrieve(content, max_results=limit, **kwargs)
def find_by_entity(self, entity_id: str, limit: int = 10) -> List[Dict[str, Any]]:
def find_by_entity(
self, entity_id: str, limit: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Find by entity.
Args:
entity_id: Entity ID to search for
limit: Maximum results (default: 10)
limit: Maximum results. None (the default) returns ALL matches.
The previous default of 10 silently truncated results — an
erasure workflow computing "what references this entity"
from a truncated page would leave the remainder live
(#1018). Callers that want pagination pass an explicit limit.
Returns:
List of memory dicts containing the entity
@@ -1308,9 +1314,9 @@ class AgentMemory:
if mem_dict:
results.append(mem_dict)
break
if len(results) >= limit:
if limit is not None and len(results) >= limit:
break
return results[:limit]
return results if limit is None else results[:limit]
def find_by_relationship(
self, relationship_type: str, limit: int = 10
+8 -2
View File
@@ -10,9 +10,10 @@ Supported Providers:
- OpenAI: OpenAI API (GPT-3.5, GPT-4, etc.)
- HuggingFaceLLM: HuggingFace Transformers for local LLM inference
- LiteLLM: Unified interface to 100+ LLM providers (OpenAI, Anthropic, Groq, Azure, Bedrock, Vertex AI, etc.)
- Anthropic: Anthropic Claude API (Claude sonnet, Opus, Haiku, etc.)
Example Usage:
>>> from semantica.llms import Groq, OpenAI, HuggingFaceLLM, LiteLLM
>>> from semantica.llms import Groq, OpenAI, HuggingFaceLLM, LiteLLM, Anthropic
>>>
>>> # Groq provider
>>> groq = Groq(model="llama-3.1-8b-instant", api_key="your-key")
@@ -32,6 +33,10 @@ Example Usage:
>>> # Or use other providers via LiteLLM
>>> llm = LiteLLM(model="anthropic/claude-sonnet-4-20250514")
>>> response = llm.generate("Hello, world!")
>>>
>>> # Anthropic provider
>>> claude = Anthropic(model="claude-3-sonnet-20240229", api_key="the-key")
>>> response = claude.generate("Hello, world!")
Author: Semantica Contributors
License: MIT
@@ -41,6 +46,7 @@ from .groq import Groq
from .openai import OpenAI
from .huggingface import HuggingFaceLLM
from .litellm import LiteLLM
from .anthropic import Anthropic
__all__ = ["Groq", "OpenAI", "HuggingFaceLLM", "LiteLLM"]
__all__ = ["Groq", "OpenAI", "HuggingFaceLLM", "LiteLLM", "Anthropic"]
+116
View File
@@ -0,0 +1,116 @@
"""
Anthropic LLM Provider
Wrapper for Anthropic Claude API provider with clean interface
"""
from typing import Any, Dict, List, Optional, Union
from ..semantic_extract.providers import AnthropicProvider
from ..utils.exceptions import ProcessingError
from ..utils.logging import get_logger
logger = get_logger("llms.anthropic")
class Anthropic:
"""
Anthropic Claude LLM provider wrapper.
Provides clean interface to Anthropic's Claude API.
Example:
>>> from semantica.llms import Anthropic
>>> claude = Anthropic(model="claude-3-sonnet-20240229", api_key="the-key")
>>> response = claude.generate("What is API key?")
"""
def __init__(
self,
model: str = "claude-3-sonnet-20240229",
api_key: Optional[str] = None,
**kwargs
):
"""
Init Anthropic provider.
Args:
model: Model name (default: claude-3-sonnet-20240229)
api_key: Anthropic API key (default: from ANTHROPIC_API_KEY env var)
**kwargs: Addition provider options
"""
self.provider = AnthropicProvider(api_key=api_key, model=model, **kwargs)
self.model = model
self.api_key = api_key
def is_available(self) -> bool:
""" Check if Anthropic provider is available"""
return self.provider.is_available()
def generate(self, prompt: str, **kwargs) -> str:
"""
Generate text from prompt.
Args:
prompt: Input prompt text
**kwargs: Generation options (temperature, max_tokens, etc.)
Returns:
Generated text response
Raises:
ProcessingError: If provider is not available or generation fails
"""
if not self.is_available():
raise ProcessingError(
"Anthropic provider not available. set ANTHROPIC_API_KEY or pass api_key."
)
return self.provider.generate(prompt, **kwargs)
def generate_structured(self, prompt: str, **kwargs) -> Union[Dict[str, Any], List[Any]]:
"""
Generates structured JSON output.
Args:
prompt: Input prompt text
**kwargs: Generation options
Returns:
Parsed JSON response. A dict for a top-level JSON object, or a
list if the model returns a top-level JSON array.
Raises:
ProcessingError: If provider is not available or generation fails
"""
if not self.is_available():
raise ProcessingError(
"Anthropic provider not available. Set ANTHROPIC_API_KEY or pass api_key."
)
return self.provider.generate_structured(prompt, **kwargs)
def generate_typed(self, prompt: str, schema: Any, max_retries: int =3, **kwargs) -> Any:
"""
Generate output validated against a Pydantic schema.
Args:
prompt: Input prompt text
schema: Pydantic model class to validate the output against
max_retries: Number of retries if validation fails (default: 3)
**kwargs: Generation options
Returns:
An instance of `schema`, populated from model's reponse
Raises:
ProcessingError: If provider is not available or generation fails
"""
if not self.is_available():
raise ProcessingError(
"Anthropic provider not available. Set ANTHROPIC_API_KEY or pass api_key."
)
return self.provider.generate_typed(prompt, schema, max_retries=max_retries, **kwargs)
+244 -92
View File
@@ -41,69 +41,134 @@ from ..utils.logging import get_logger
from ..utils.progress_tracker import get_progress_tracker
from .reasoner import Fact, Rule, _make_activation_key
logger = get_logger("rete_engine")
def _extract_bindings(condition: Any, fact: Fact) -> Dict[str, Any]:
"""Extract ``?var`` bindings by matching a condition pattern against a fact.
``condition`` is the pattern stored on the alpha node (typically a string
like ``"Person(?x)"``); ``fact`` is the working-memory :class:`Fact`. The
fact's canonical string form (``Predicate(arg1, arg2, ...)``) is matched
against the pattern using the same ``?\\w+`` placeholder convention as the
Reasoner, so downstream actions receive real bindings (e.g. ``{"x": "John"}``)
instead of the empty dict that previously left ``?x`` placeholders
unsubstituted.
def _build_condition_regex(
pattern: str,
initial_bindings: Optional[Dict[str, str]] = None,
) -> str:
"""Build an anchored regex string for a condition pattern.
Returns an empty dict when the condition is not a string pattern or does
not match -- callers treat that as "no bindings extracted".
Splits the pattern on ``?var`` placeholders, escaping the literal
segments so surrounding parentheses/commas match literally. Variables
become named groups (or backreferences when repeated); variables already
present in ``initial_bindings`` are inlined as their literal value.
Args:
pattern: The condition pattern string (e.g. ``"Person(?x)"``).
initial_bindings: Bindings already established upstream. Variables
already bound are matched as literals rather than captured.
Returns:
An anchored regex string (``^...$``) suitable for ``re.compile`` /
``re.match``.
"""
if not isinstance(condition, str):
return {}
segments = re.split(r"(\?\w+)", condition)
bindings = initial_bindings or {}
segments = re.split(r"(\?\w+)", pattern)
seen_vars: Set[str] = set()
p_regex = ""
for seg in segments:
if seg.startswith("?"):
var_name = seg[1:]
if var_name in seen_vars:
if var_name in bindings:
# Already bound — require the exact literal value.
p_regex += re.escape(bindings[var_name])
elif var_name in seen_vars:
# Same variable used twice — enforce a backreference.
p_regex += f"(?P={var_name})"
else:
p_regex += f"(?P<{var_name}>.+?)"
seen_vars.add(var_name)
else:
p_regex += re.escape(seg)
p_regex = f"^{p_regex}$"
return f"^{p_regex}$"
def unify_condition(
condition: Any,
fact: Fact,
initial_bindings: Optional[Dict[str, str]] = None,
) -> Optional[Dict[str, str]]:
"""Unify a condition pattern against a fact.
A condition is a pattern string such as ``"Person(?x)"`` or
``"knows(?x, ?y)"`` where tokens beginning with ``?`` are variables.
The fact is rendered via its ``__str__`` representation
(``predicate(arg1, arg2)``) and matched against the pattern.
This mirrors ``Reasoner._match_pattern`` but is self-contained so the
RETE engine does not need a live ``Reasoner`` instance.
Args:
condition: The condition pattern (string). Non-string conditions
are stringified before matching.
fact: The fact to test.
initial_bindings: Bindings already established upstream. Variables
already bound must match the corresponding literal in the fact.
Returns:
A dict of variable bindings if the fact unifies with the condition,
otherwise ``None``.
"""
bindings = dict(initial_bindings or {})
pattern = condition if isinstance(condition, str) else str(condition)
fact_str = str(fact)
# Build the anchored regex once (variables already bound are inlined as
# literals). See ``_build_condition_regex`` for the segment handling.
p_regex = _build_condition_regex(pattern, bindings)
try:
match = re.match(p_regex, str(fact))
except re.error:
return {}
match = re.match(p_regex, fact_str)
except re.error as e:
logger.warning(
"unify_condition failed to compile/match condition "
"%r (regex: %r) against fact %r: %s",
pattern,
p_regex,
fact_str,
e,
)
return None
except Exception as e: # noqa: BLE001 - mirror Reasoner._match_pattern
logger.warning(
"unify_condition unexpected error matching condition "
"%r (regex: %r) against fact %r: %s",
pattern,
p_regex,
fact_str,
e,
)
return None
if not match:
return {}
return {k: v for k, v in match.groupdict().items() if v is not None}
return None
def _bindings_for_rule(rule: Rule, facts: List[Fact]) -> Dict[str, Any]:
"""Merge ``?var`` bindings from matching a rule's conditions against facts.
Each fact is matched against every condition of the rule; the first
condition that yields bindings for a fact contributes them. Bindings from
all facts are merged so multi-condition (joined) rules receive the full
variable environment. Later conflicting values do not overwrite earlier
ones, preserving the binding that a join already validated.
"""
bindings: Dict[str, Any] = {}
for fact in facts:
for condition in rule.conditions:
extracted = _extract_bindings(condition, fact)
if not extracted:
continue
for key, value in extracted.items():
bindings.setdefault(key, value)
break
for var, value in match.groupdict().items():
if var in bindings and bindings[var] != value:
return None # Binding conflict.
bindings[var] = value
return bindings
@dataclass
class Token:
"""A partial match flowing through the Rete network.
A token represents an ordered collection of concrete facts that have
been unified so far, together with the consistent variable bindings
accumulated across those facts.
Alpha nodes emit single-fact tokens. Beta nodes merge a left token and
a right token into a new token whose ``facts`` are the concatenation of
both sides (preserving condition order) and whose ``bindings`` are the
consistent union of both sides.
"""
facts: List[Fact] = field(default_factory=list)
bindings: Dict[str, str] = field(default_factory=dict)
@dataclass
class Match:
"""Pattern match."""
@@ -128,19 +193,68 @@ class AlphaNode(ReteNode):
def __init__(self, node_id: str, condition: Any):
super().__init__(node_id)
self.condition = condition
self.matches: List[Fact] = []
# Single-fact tokens produced by unifying each matched fact with
# this node's condition.
self.tokens: List[Token] = []
# Pre-compile the condition regex once. Alpha nodes never have
# initial bindings, so the pattern is stable for the node's lifetime
# and every incoming fact reuses this compiled matcher instead of
# rebuilding it (avoids repeated regex construction overhead).
pattern = condition if isinstance(condition, str) else str(condition)
self._compiled: Optional[re.Pattern] = None
try:
self._compiled = re.compile(_build_condition_regex(pattern))
except re.error as e:
logger.warning(
"AlphaNode %r failed to compile condition %r: %s; "
"node will never match",
node_id,
pattern,
e,
)
def add_fact(self, fact: Fact) -> bool:
"""Add fact if it matches condition."""
if self._matches(fact):
self.matches.append(fact)
return True
return False
def add_fact(self, fact: Fact) -> Optional[Token]:
"""Add fact if it matches the condition, returning its token.
def _matches(self, fact: Fact) -> bool:
"""Check if fact matches condition."""
# Simple matching - can be enhanced
return True
Returns the single-fact ``Token`` produced by unification when the
fact matches, otherwise ``None``.
"""
bindings = self._matches(fact)
if bindings is not None:
token = Token(facts=[fact], bindings=dict(bindings))
self.tokens.append(token)
return token
return None
def _matches(self, fact: Fact) -> Optional[Dict[str, str]]:
"""Check if fact matches the alpha node condition.
Uses the pre-compiled regex built in ``__init__`` for performance,
since RETE evaluates many facts against every alpha node.
Returns the variable bindings produced by unification if the fact
matches, otherwise ``None``. An empty dict signals a match with no
variables (still distinct from ``None``).
"""
if self._compiled is None:
# Compilation failed at build time; treat as non-matching.
return None
fact_str = str(fact)
try:
match = self._compiled.match(fact_str)
except Exception as e: # noqa: BLE001 - mirror unify_condition
logger.warning(
"AlphaNode %r unexpected error matching condition "
"%r against fact %r: %s",
self.node_id,
self.condition,
fact_str,
e,
)
return None
if not match:
return None
return match.groupdict()
class BetaNode(ReteNode):
@@ -150,19 +264,28 @@ class BetaNode(ReteNode):
super().__init__(node_id)
self.left = left
self.right = right
self.matches: List[Tuple[Fact, Fact]] = []
# Token memories for each side. Incoming tokens are stored here so
# that later-arriving tokens on the opposite side can be joined
# against every token already seen (chained joins).
self.left_tokens: List[Token] = []
self.right_tokens: List[Token] = []
def join(self, left_fact: Fact, right_fact: Fact) -> bool:
"""Join facts from left and right nodes."""
if self._can_join(left_fact, right_fact):
self.matches.append((left_fact, right_fact))
return True
return False
def join(self, left_token: Token, right_token: Token) -> Optional[Token]:
"""Join a left token with a right token.
def _can_join(self, left_fact: Fact, right_fact: Fact) -> bool:
"""Check if facts can be joined."""
# Simple join logic - can be enhanced
return True
Returns a new merged ``Token`` (facts concatenated in condition
order, bindings unified) when the two tokens are consistent,
otherwise ``None`` on a binding conflict.
"""
merged = dict(left_token.bindings)
for var, value in right_token.bindings.items():
if var in merged and merged[var] != value:
return None # Binding conflict — cannot join.
merged[var] = value
return Token(
facts=list(left_token.facts) + list(right_token.facts),
bindings=merged,
)
class TerminalNode(ReteNode):
@@ -248,12 +371,16 @@ class ReteEngine:
self._add_rule_to_network(rule)
self.logger.info(
f"Built Rete network with {len(self.network)} nodes for {len(rules)} rules"
f"Built Rete network with {len(self.network)} nodes "
f"for {len(rules)} rules"
)
self.progress_tracker.stop_tracking(
tracking_id,
status="completed",
message=f"Built Rete network with {len(self.network)} nodes for {len(rules)} rules",
message=(
f"Built Rete network with {len(self.network)} nodes "
f"for {len(rules)} rules"
),
)
except Exception as e:
@@ -281,6 +408,10 @@ class ReteEngine:
self.node_counter += 1
beta_node = BetaNode(node_id, current, alpha_nodes[i])
self.network[node_id] = beta_node
# Wire the beta node as a child of both its inputs so facts
# propagating from either side reach the join.
current.children.append(beta_node)
alpha_nodes[i].children.append(beta_node)
current = beta_node
final_node = current
else:
@@ -311,40 +442,58 @@ class ReteEngine:
# Find matching alpha nodes
for node_id, node in self.network.items():
if isinstance(node, AlphaNode):
if node.add_fact(fact):
# Propagate to children
self._propagate_from_alpha(node, fact)
token = node.add_fact(fact)
if token is not None:
# Propagate the single-fact token to children.
self._propagate_token(node, token)
def _propagate_from_alpha(self, alpha_node: AlphaNode, fact: Fact) -> None:
"""Propagate from alpha node to children."""
for child in alpha_node.children:
def _propagate_token(self, source: ReteNode, token: Token) -> None:
"""Propagate ``token`` (arriving from ``source``) to its children.
A ``Token`` carries the ordered facts and consistent bindings of a
partial match. Beta children attempt joins and, on success, emit a
new merged token downstream; terminal children turn the token into a
rule activation using the token's complete facts and bindings.
"""
for child in source.children:
if isinstance(child, BetaNode):
# Join with matches from left side
for left_fact in alpha_node.matches:
if child.join(left_fact, fact):
# Propagate to children
for grandchild in child.children:
if isinstance(grandchild, TerminalNode):
facts = [left_fact, fact]
match = Match(
rule=grandchild.rule,
facts=facts,
bindings=_bindings_for_rule(
grandchild.rule, facts
),
confidence=1.0,
)
grandchild.activate(match)
self._propagate_to_beta(child, source, token)
elif isinstance(child, TerminalNode):
# Direct activation
match = Match(
rule=child.rule,
facts=[fact],
bindings=_bindings_for_rule(child.rule, [fact]),
facts=list(token.facts),
bindings=dict(token.bindings),
confidence=1.0,
)
child.activate(match)
def _propagate_to_beta(
self,
beta: "BetaNode",
source: ReteNode,
token: Token,
) -> None:
"""Attempt joins at ``beta`` for a token arriving from one side.
The incoming token is stored in the corresponding side's memory,
then joined against every token already recorded on the opposite
side. Each successful join produces a new merged token that is
propagated further downstream, enabling correct chained joins across
three or more conditions.
"""
if source is beta.left:
beta.left_tokens.append(token)
for right_token in list(beta.right_tokens):
merged = beta.join(token, right_token)
if merged is not None:
self._propagate_token(beta, merged)
elif source is beta.right:
beta.right_tokens.append(token)
for left_token in list(beta.left_tokens):
merged = beta.join(left_token, token)
if merged is not None:
self._propagate_token(beta, merged)
def match_patterns(self, facts: Optional[List[Fact]] = None) -> List[Match]:
"""
Match patterns using Rete algorithm.
@@ -468,8 +617,11 @@ class ReteEngine:
self.facts.clear()
self.reset_action_history()
for node in self.network.values():
if isinstance(node, AlphaNode) or isinstance(node, BetaNode):
node.matches.clear()
if isinstance(node, AlphaNode):
node.tokens.clear()
elif isinstance(node, BetaNode):
node.left_tokens.clear()
node.right_tokens.clear()
elif isinstance(node, TerminalNode):
node.activations.clear()
+16 -9
View File
@@ -64,6 +64,8 @@ class SlidingWindowChunker:
raise ValidationError("overlap must be non-negative")
if self.overlap >= self.chunk_size:
raise ValidationError("overlap must be less than chunk_size")
if self.stride <= 0:
raise ValidationError("stride must be positive")
def chunk(self, text: str, **options) -> List[Chunk]:
"""
@@ -215,15 +217,20 @@ class SlidingWindowChunker:
Returns:
list: List of chunks
"""
if overlap_size is None:
return self.chunk(text)
if overlap_size < 0:
raise ValidationError("overlap_size must be non-negative")
if overlap_size >= self.chunk_size:
raise ValidationError("overlap_size must be less than chunk_size")
original_overlap = self.overlap
if overlap_size is not None:
original_stride = self.stride
try:
self.overlap = overlap_size
self.stride = self.chunk_size - self.overlap
chunks = self.chunk(text)
# Restore original overlap
self.overlap = original_overlap
self.stride = self.chunk_size - self.overlap
return chunks
return self.chunk(text)
finally:
self.overlap = original_overlap
self.stride = original_stride
@@ -0,0 +1,42 @@
"""AgentMemory.find_by_entity returns all matches by default (#1018).
The previous default limit of 10 silently truncated results, making erasure
workflows incomplete for entities with more than 10 memories: a caller
computing "what references this entity" from a truncated page would leave
the remainder live. The unbounded default is deliberate — an erasure check
cannot paginate — while callers that want a page still pass an explicit
limit. (Previously lived in tests/test_seed_manager.py; moved to the
AgentMemory area per review.)
"""
import os
import sys
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from semantica.context.agent_memory import AgentMemory
@pytest.fixture
def memory_with_15():
mem = AgentMemory()
for i in range(15):
mem.store(
content=f"fact {i} about entity",
entities=[{"id": "e1", "name": "Entity", "type": "thing"}],
)
return mem
class TestFindByEntityLimit:
def test_returns_all_matches_by_default(self, memory_with_15):
results = memory_with_15.find_by_entity("e1")
assert len(results) == 15, f"expected 15 (all), got {len(results)}"
def test_explicit_limit_still_works(self, memory_with_15):
assert len(memory_with_15.find_by_entity("e1", limit=5)) == 5
def test_no_matches_returns_empty(self):
assert AgentMemory().find_by_entity("nonexistent") == []
@@ -24,14 +24,19 @@ import math
from datetime import datetime
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the imports below, which need that extra.
pytest.importorskip("fastapi")
from semantica.context.context_graph import ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.routes.decisions import _node_to_decision
from semantica.explorer.schemas import DecisionResponse
from semantica.explorer.session import GraphSession
from fastapi.testclient import TestClient # noqa: E402
from pydantic import ValidationError # noqa: E402
from semantica.context.context_graph import ContextGraph # noqa: E402
from semantica.explorer.app import create_app # noqa: E402
from semantica.explorer.routes.decisions import _node_to_decision # noqa: E402
from semantica.explorer.schemas import DecisionResponse # noqa: E402
from semantica.explorer.session import GraphSession # noqa: E402
# ---------------------------------------------------------------------------
+9 -10
View File
@@ -9,16 +9,15 @@ import networkx as nx
import pytest
from semantica.context.context_graph import ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.session import GraphSession
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the import below, which pulls fastapi in transitively.
pytest.importorskip("fastapi")
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip(
"starlette TestClient is required for explorer tests. Install semantica[explorer].",
allow_module_level=True,
)
from semantica.explorer.app import create_app # noqa: E402
from semantica.explorer.session import GraphSession # noqa: E402
from starlette.testclient import TestClient # noqa: E402
@@ -1104,7 +1103,7 @@ class TestBidirectionalPathRoute:
# _classify_distance unit tests — issue #472
# ---------------------------------------------------------------------------
from semantica.utils.helpers import classify_path_distance
from semantica.utils.helpers import classify_path_distance # noqa: E402
class _FakeSimilarity:
+8 -9
View File
@@ -13,16 +13,15 @@ browsers can't set custom headers on a WebSocket handshake.
import pytest
from semantica.context.context_graph import ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.session import GraphSession
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the import below, which pulls fastapi in transitively.
pytest.importorskip("fastapi")
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip(
"starlette TestClient is required for explorer tests. Install semantica[explorer].",
allow_module_level=True,
)
from semantica.explorer.app import create_app # noqa: E402
from semantica.explorer.session import GraphSession # noqa: E402
from starlette.testclient import TestClient # noqa: E402
def _build_sample_graph() -> ContextGraph:
@@ -15,9 +15,14 @@ from pathlib import Path
import pytest
from semantica.context.context_graph import ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.session import GraphSession
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the explorer imports below, which pull fastapi in transitively.
pytest.importorskip("fastapi")
from semantica.context.context_graph import ContextGraph # noqa: E402
from semantica.explorer.app import create_app # noqa: E402
from semantica.explorer.session import GraphSession # noqa: E402
try:
from starlette.testclient import TestClient
+6 -1
View File
@@ -27,7 +27,12 @@ import threading
import pytest
from semantica.explorer.routes import ontology as ontology_mod
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the import below, which pulls fastapi in transitively.
pytest.importorskip("fastapi")
from semantica.explorer.routes import ontology as ontology_mod # noqa: E402
def _start_local_server():
+6 -1
View File
@@ -21,7 +21,12 @@ from unittest.mock import MagicMock, patch
import pytest
from semantica.explorer.routes import ontology as ontology_mod
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the import below, which pulls fastapi in transitively.
pytest.importorskip("fastapi")
from semantica.explorer.routes import ontology as ontology_mod # noqa: E402
def _fake_getaddrinfo(host, *args, **kwargs):
+9 -10
View File
@@ -6,17 +6,16 @@ from urllib.parse import quote
import pytest
from semantica.context.context_graph import ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.routes.ontology import OntologyEntry
from semantica.explorer.session import GraphSession
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the import below, which pulls fastapi in transitively.
pytest.importorskip("fastapi")
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip(
"starlette TestClient is required for explorer tests. Install semantica[explorer].",
allow_module_level=True,
)
from semantica.explorer.app import create_app # noqa: E402
from semantica.explorer.routes.ontology import OntologyEntry # noqa: E402
from semantica.explorer.session import GraphSession # noqa: E402
from starlette.testclient import TestClient # noqa: E402
def _build_ontology_graph() -> ContextGraph:
@@ -5,13 +5,18 @@ Tests for ProvenanceManager wiring into Explorer routes and application startup.
from unittest.mock import patch
import pytest
from starlette.testclient import TestClient
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the starlette/explorer imports below, which need that extra.
pytest.importorskip("fastapi")
from semantica.context.context_graph import ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.session import GraphSession
from semantica.provenance import ProvenanceManager
from semantica.provenance.storage import SQLiteStorage
from starlette.testclient import TestClient # noqa: E402
from semantica.context.context_graph import ContextGraph # noqa: E402
from semantica.explorer.app import create_app # noqa: E402
from semantica.explorer.session import GraphSession # noqa: E402
from semantica.provenance import ProvenanceManager # noqa: E402
from semantica.provenance.storage import SQLiteStorage # noqa: E402
@pytest.fixture
+8 -1
View File
@@ -2,7 +2,14 @@
from types import SimpleNamespace
from semantica.explorer.routes.provenance import (
import pytest
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the import below, which pulls fastapi in transitively.
pytest.importorskip("fastapi")
from semantica.explorer.routes.provenance import ( # noqa: E402
_add_chain_edges,
_build_provenance,
_render_markdown,
+9 -10
View File
@@ -14,18 +14,17 @@ from unittest.mock import patch
import pytest
from semantica.context.context_graph import ContextGraph
from semantica.explorer.app import create_app
from semantica.explorer.session import GraphSession
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the import below, which pulls fastapi in transitively.
pytest.importorskip("fastapi")
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip(
"starlette TestClient is required for explorer tests. Install semantica[explorer].",
allow_module_level=True,
)
from semantica.explorer.app import create_app # noqa: E402
from semantica.explorer.session import GraphSession # noqa: E402
import semantica.explorer.routes.sparql as sparql_mod
from starlette.testclient import TestClient # noqa: E402
import semantica.explorer.routes.sparql as sparql_mod # noqa: E402
def _build_sample_graph() -> ContextGraph:
+12 -5
View File
@@ -2,12 +2,19 @@
from unittest.mock import MagicMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
from semantica.explorer.dependencies import get_session
from semantica.explorer.routes.vocabulary import router
from semantica.utils.skos import validate_skos_hierarchy
# fastapi ships in the optional `explorer` extra, not in `dev`, so this module
# must skip rather than fail collection when it is absent. The guard has to sit
# above the import below, which pulls fastapi in transitively.
pytest.importorskip("fastapi")
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from semantica.explorer.dependencies import get_session # noqa: E402
from semantica.explorer.routes.vocabulary import router # noqa: E402
from semantica.utils.skos import validate_skos_hierarchy # noqa: E402
app = FastAPI()
app.include_router(router)
+366
View File
@@ -0,0 +1,366 @@
"""Tests for the RETE engine pattern matching (issue #300).
These tests verify that ``AlphaNode._matches`` and ``BetaNode._can_join`` no
longer behave like the old always-``True`` stubs, and that the network as a
whole only fires rules whose conditions actually unify with the facts.
"""
import itertools
import re
import unittest
from unittest import mock
from semantica.reasoning import rete_engine
from semantica.reasoning.reasoner import Fact, Rule
from semantica.reasoning.rete_engine import (
AlphaNode,
BetaNode,
ReteEngine,
unify_condition,
)
class TestUnifyCondition(unittest.TestCase):
def test_single_variable_binds(self):
fact = Fact("f1", "Person", ["John"])
bindings = unify_condition("Person(?x)", fact)
self.assertEqual(bindings, {"x": "John"})
def test_predicate_mismatch_returns_none(self):
fact = Fact("f1", "Company", ["Google"])
self.assertIsNone(unify_condition("Person(?x)", fact))
def test_two_arguments_bind(self):
fact = Fact("f2", "Parent", ["John", "Mary"])
bindings = unify_condition("Parent(?x, ?y)", fact)
self.assertEqual(bindings, {"x": "John", "y": "Mary"})
def test_literal_argument_must_match(self):
fact = Fact("f3", "Parent", ["John", "Mary"])
self.assertIsNone(unify_condition("Parent(Bob, ?y)", fact))
self.assertEqual(unify_condition("Parent(John, ?y)", fact), {"y": "Mary"})
def test_repeated_variable_requires_equal_values(self):
loves_self = Fact("f4", "Loves", ["John", "John"])
loves_other = Fact("f5", "Loves", ["John", "Mary"])
self.assertEqual(unify_condition("Loves(?x, ?x)", loves_self), {"x": "John"})
self.assertIsNone(unify_condition("Loves(?x, ?x)", loves_other))
def test_regex_error_logs_warning_and_returns_none(self):
"""A regex compilation error is logged with context and yields None."""
fact = Fact("f6", "Person", ["John"])
with mock.patch.object(
rete_engine.re,
"match",
side_effect=re.error("bad pattern"),
), self.assertLogs("semantica.rete_engine", level="WARNING") as captured:
result = unify_condition("Person(?x)", fact)
self.assertIsNone(result)
joined = "\n".join(captured.output)
self.assertIn("Person(?x)", joined)
self.assertIn("Person(John)", joined)
self.assertIn("bad pattern", joined)
def test_unexpected_error_logs_warning_and_returns_none(self):
"""An unexpected error is also logged and swallowed as None."""
fact = Fact("f7", "Person", ["John"])
with mock.patch.object(
rete_engine.re,
"match",
side_effect=RuntimeError("boom"),
), self.assertLogs("semantica.rete_engine", level="WARNING") as captured:
result = unify_condition("Person(?x)", fact)
self.assertIsNone(result)
self.assertIn("boom", "\n".join(captured.output))
class TestAlphaNode(unittest.TestCase):
def test_matches_stores_bindings(self):
node = AlphaNode("a1", "Person(?x)")
fact = Fact("f1", "Person", ["John"])
token = node.add_fact(fact)
self.assertIsNotNone(token)
assert token is not None # narrow type for the checker
self.assertEqual(token.facts, [fact])
self.assertEqual(token.bindings, {"x": "John"})
self.assertIn(token, node.tokens)
def test_non_matching_fact_rejected(self):
node = AlphaNode("a1", "Person(?x)")
fact = Fact("f1", "Company", ["Google"])
self.assertIsNone(node.add_fact(fact))
self.assertEqual(node.tokens, [])
def test_uses_precompiled_regex(self):
"""AlphaNode compiles its condition once and reuses it per fact."""
node = AlphaNode("a1", "Person(?x)")
self.assertIsNotNone(node._compiled)
# Matching goes through the compiled matcher, not unify_condition.
with mock.patch.object(rete_engine, "unify_condition") as unify:
fact = Fact("f1", "Person", ["John"])
token = node.add_fact(fact)
unify.assert_not_called()
self.assertIsNotNone(token)
assert token is not None
self.assertEqual(token.bindings, {"x": "John"})
def test_bad_condition_never_matches_and_logs(self):
"""A condition that fails to compile logs a warning and never fires."""
with mock.patch.object(
rete_engine,
"_build_condition_regex",
return_value="(unbalanced",
), self.assertLogs("semantica.rete_engine", level="WARNING") as captured:
node = AlphaNode("bad", "Person(?x)")
self.assertIsNone(node._compiled)
self.assertIn("failed to compile", "\n".join(captured.output))
fact = Fact("f1", "Person", ["John"])
self.assertIsNone(node.add_fact(fact))
self.assertEqual(node.tokens, [])
class TestBetaNode(unittest.TestCase):
def test_join_consistent_bindings(self):
left = AlphaNode("a1", "Parent(?x, ?y)")
right = AlphaNode("a2", "Person(?x)")
beta = BetaNode("b1", left, right)
parent = Fact("f1", "Parent", ["John", "Mary"])
person = Fact("f2", "Person", ["John"])
left_token = left.add_fact(parent)
right_token = right.add_fact(person)
assert left_token is not None and right_token is not None
merged = beta.join(left_token, right_token)
self.assertIsNotNone(merged)
assert merged is not None # narrow type for the checker
self.assertEqual(merged.bindings, {"x": "John", "y": "Mary"})
# Facts are concatenated left-then-right in condition order.
self.assertEqual(merged.facts, [parent, person])
def test_join_conflicting_bindings_rejected(self):
left = AlphaNode("a1", "Parent(?x, ?y)")
right = AlphaNode("a2", "Person(?x)")
beta = BetaNode("b1", left, right)
parent = Fact("f1", "Parent", ["John", "Mary"])
# ?x conflicts: John vs Alice
person = Fact("f2", "Person", ["Alice"])
left_token = left.add_fact(parent)
right_token = right.add_fact(person)
assert left_token is not None and right_token is not None
self.assertIsNone(beta.join(left_token, right_token))
class TestReteEngineEndToEnd(unittest.TestCase):
def test_only_matching_rule_fires(self):
engine = ReteEngine()
rule = Rule(
rule_id="r1",
name="person rule",
conditions=["Person(?x)"],
conclusion="Mortal(?x)",
)
engine.build_network([rule])
engine.add_fact(Fact("f1", "Person", ["John"]))
engine.add_fact(Fact("f2", "Company", ["Google"])) # should NOT fire
matches = engine.match_patterns()
self.assertEqual(len(matches), 1)
self.assertEqual(matches[0].bindings, {"x": "John"})
def test_multi_condition_join(self):
engine = ReteEngine()
rule = Rule(
rule_id="r1",
name="child rule",
conditions=["Person(?x)", "Parent(?x, ?y)"],
conclusion="Child(?y, ?x)",
)
engine.build_network([rule])
engine.add_fact(Fact("f1", "Person", ["John"]))
engine.add_fact(Fact("f2", "Parent", ["John", "Mary"]))
# Unrelated parent whose ?x does not match any Person -> no activation.
engine.add_fact(Fact("f3", "Parent", ["Bob", "Sue"]))
matches = engine.match_patterns()
self.assertEqual(len(matches), 1)
self.assertEqual(matches[0].bindings, {"x": "John", "y": "Mary"})
def test_no_activation_when_join_inconsistent(self):
engine = ReteEngine()
rule = Rule(
rule_id="r1",
name="child rule",
conditions=["Person(?x)", "Parent(?x, ?y)"],
conclusion="Child(?y, ?x)",
)
engine.build_network([rule])
engine.add_fact(Fact("f1", "Person", ["John"]))
engine.add_fact(Fact("f2", "Parent", ["Alice", "Mary"])) # ?x mismatch
matches = engine.match_patterns()
self.assertEqual(matches, [])
class TestThreeConditionChain(unittest.TestCase):
"""Chained beta joins across three or more conditions (issue #300).
These exercise the Token model: a token must accumulate the ordered
facts and the consistent bindings of every condition, so that deep
chains neither drop bindings nor duplicate facts, and a conflict on the
third condition correctly suppresses activation.
"""
def _three_condition_rule(self):
return Rule(
rule_id="r1",
name="location chain",
conditions=[
"Person(?x)",
"Parent(?x, ?y)",
"Located(?y, ?z)",
],
conclusion="LivesNear(?x, ?z)",
)
def test_three_condition_valid_match(self):
engine = ReteEngine()
engine.build_network([self._three_condition_rule()])
engine.add_fact(Fact("f1", "Person", ["John"]))
engine.add_fact(Fact("f2", "Parent", ["John", "Mary"]))
engine.add_fact(Fact("f3", "Located", ["Mary", "Paris"]))
matches = engine.match_patterns()
self.assertEqual(len(matches), 1)
self.assertEqual(
matches[0].bindings,
{"x": "John", "y": "Mary", "z": "Paris"},
)
def test_three_condition_third_level_conflict(self):
engine = ReteEngine()
engine.build_network([self._three_condition_rule()])
engine.add_fact(Fact("f1", "Person", ["John"]))
engine.add_fact(Fact("f2", "Parent", ["John", "Mary"]))
# ?y is bound to Mary, so a Located fact about Bob must not join.
engine.add_fact(Fact("f3", "Located", ["Bob", "Paris"]))
matches = engine.match_patterns()
self.assertEqual(matches, [])
def test_fact_insertion_order_independent(self):
# Whatever order facts arrive, the same single match must result.
base_facts = [
Fact("f1", "Person", ["John"]),
Fact("f2", "Parent", ["John", "Mary"]),
Fact("f3", "Located", ["Mary", "Paris"]),
]
expected = {"x": "John", "y": "Mary", "z": "Paris"}
for order in itertools.permutations(base_facts):
engine = ReteEngine()
engine.build_network([self._three_condition_rule()])
for fact in order:
engine.add_fact(fact)
matches = engine.match_patterns()
self.assertEqual(len(matches), 1, f"order={order}")
self.assertEqual(matches[0].bindings, expected)
def test_match_facts_complete_in_condition_order(self):
engine = ReteEngine()
engine.build_network([self._three_condition_rule()])
person = Fact("f1", "Person", ["John"])
parent = Fact("f2", "Parent", ["John", "Mary"])
located = Fact("f3", "Located", ["Mary", "Paris"])
engine.add_fact(person)
engine.add_fact(parent)
engine.add_fact(located)
matches = engine.match_patterns()
self.assertEqual(len(matches), 1)
# All three facts preserved, in condition order, no duplicates.
self.assertEqual(matches[0].facts, [person, parent, located])
def test_multiple_left_tokens_join_one_right_fact(self):
# Two Person/Parent chains sharing the same Located(?y, ?z) fact.
engine = ReteEngine()
engine.build_network([self._three_condition_rule()])
engine.add_fact(Fact("f1", "Person", ["John"]))
engine.add_fact(Fact("f2", "Parent", ["John", "Mary"]))
engine.add_fact(Fact("f3", "Person", ["Alice"]))
engine.add_fact(Fact("f4", "Parent", ["Alice", "Mary"]))
# One right fact should join with both accumulated left tokens.
engine.add_fact(Fact("f5", "Located", ["Mary", "Paris"]))
matches = engine.match_patterns()
self.assertEqual(len(matches), 2)
result = {m.bindings["x"]: m.bindings["z"] for m in matches}
self.assertEqual(result, {"John": "Paris", "Alice": "Paris"})
def test_matches_reasoner_match_rule(self):
from semantica.reasoning.reasoner import Reasoner
rule = self._three_condition_rule()
facts = [
Fact("f1", "Person", ["John"]),
Fact("f2", "Parent", ["John", "Mary"]),
Fact("f3", "Located", ["Mary", "Paris"]),
]
# Reasoner works over stringified facts and returns
# (conclusion, matched_facts, bindings) tuples from self.facts.
reasoner = Reasoner()
for fact in facts:
reasoner.add_fact(str(fact))
reasoner_matches = reasoner._match_rule(rule)
engine = ReteEngine()
engine.build_network([rule])
for fact in facts:
engine.add_fact(fact)
rete_matches = engine.match_patterns()
# Both engines must agree on the number of activations.
self.assertEqual(len(rete_matches), len(reasoner_matches))
self.assertEqual(len(rete_matches), 1)
self.assertEqual(
rete_matches[0].bindings,
{"x": "John", "y": "Mary", "z": "Paris"},
)
# The RETE match must carry the instantiated conclusion facts too.
conclusion, _, _ = reasoner_matches[0]
self.assertEqual(conclusion, "LivesNear(John, Paris)")
def test_reset_clears_all_token_memory(self):
engine = ReteEngine()
engine.build_network([self._three_condition_rule()])
engine.add_fact(Fact("f1", "Person", ["John"]))
engine.add_fact(Fact("f2", "Parent", ["John", "Mary"]))
engine.add_fact(Fact("f3", "Located", ["Mary", "Paris"]))
self.assertEqual(len(engine.match_patterns()), 1)
engine.reset()
# No stale facts, tokens or activations remain anywhere.
self.assertEqual(engine.facts, [])
for node in engine.network.values():
if isinstance(node, AlphaNode):
self.assertEqual(node.tokens, [])
elif isinstance(node, BetaNode):
self.assertEqual(node.left_tokens, [])
self.assertEqual(node.right_tokens, [])
self.assertEqual(engine.match_patterns(), [])
if __name__ == "__main__":
unittest.main()
+48
View File
@@ -60,6 +60,11 @@ class TestSlidingWindowChunker:
with pytest.raises(ValidationError):
SlidingWindowChunker(chunk_size=100, overlap=100)
@pytest.mark.parametrize("stride", [0, -1])
def test_init_rejects_non_positive_stride(self, stride):
with pytest.raises(ValidationError, match="stride must be positive"):
SlidingWindowChunker(chunk_size=100, stride=stride)
def test_empty_text_returns_empty(self):
chunker = SlidingWindowChunker(chunk_size=50, overlap=10)
assert chunker.chunk("") == []
@@ -97,6 +102,49 @@ class TestSlidingWindowChunker:
chunks = chunker.chunk_with_overlap(text, overlap_size=15)
assert len(chunks) >= 2
assert chunker.overlap == 0
assert chunker.stride == 50
@pytest.mark.parametrize("overlap_size", [-1, 50, 51])
def test_chunk_with_overlap_rejects_invalid_override(self, overlap_size):
chunker = SlidingWindowChunker(chunk_size=50)
with pytest.raises(ValidationError):
chunker.chunk_with_overlap(
"non-empty input", overlap_size=overlap_size
)
def test_chunk_with_overlap_accepts_largest_valid_override(self):
chunker = SlidingWindowChunker(chunk_size=5)
chunks = chunker.chunk_with_overlap("abcdefghij", overlap_size=4)
assert [chunk.start_index for chunk in chunks] == list(range(10))
def test_chunk_with_overlap_restores_custom_stride(self):
chunker = SlidingWindowChunker(chunk_size=10, overlap=2, stride=3)
chunker.chunk_with_overlap(
"abcdefghijklmnopqrstuvwxyz", overlap_size=4
)
assert chunker.overlap == 2
assert chunker.stride == 3
def test_chunk_with_overlap_restores_state_when_chunk_raises(
self, monkeypatch
):
chunker = SlidingWindowChunker(chunk_size=10, overlap=2, stride=3)
def raise_error(text):
raise RuntimeError("chunk failed")
monkeypatch.setattr(chunker, "chunk", raise_error)
with pytest.raises(RuntimeError, match="chunk failed"):
chunker.chunk_with_overlap("non-empty input", overlap_size=4)
assert chunker.overlap == 2
assert chunker.stride == 3
def test_boundary_preservation_avoids_mid_word_when_possible(self):
text = (
+76
View File
@@ -0,0 +1,76 @@
"""Tests for the Anthropic LLM provider wrapper (semantica.llms.Anthropic)."""
from unittest.mock import MagicMock
import pytest
from semantica.llms import Anthropic
from semantica.utils.exceptions import ProcessingError
def test_construction_stores_model_and_api_key():
"""Anthropic(...) should not crash and should remember what it was given."""
claude = Anthropic(model="claude-3-sonnet-20240229", api_key="fake-key")
assert claude.model == "claude-3-sonnet-20240229"
assert claude.api_key == "fake-key"
def test_is_available_false_with_no_key(monkeypatch):
"""Without a real key, is_available() must be a real False, not truthy junk.
api_key=None alone isn't enough to prove this: AnthropicProvider falls
back to the ANTHROPIC_API_KEY environment variable, so this test has to
clear it too or it would pass/fail depending on whoever's machine or CI
runner happens to run it.
"""
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
claude = Anthropic(api_key=None)
assert claude.is_available() is False
def test_generate_raises_clear_error_when_unavailable():
"""generate() must fail loudly."""
claude = Anthropic(api_key=None)
with pytest.raises(ProcessingError, match="Anthropic provider not available"):
claude.generate("hello")
def test_generate_forwards_to_the_real_provider_when_available():
"""When available, generate() must actually call through to the real provider."""
claude = Anthropic(api_key="fake-key")
claude.provider = MagicMock()
claude.provider.is_available.return_value = True
claude.provider.generate.return_value = "a fake response"
result = claude.generate("hello", temperature=0.5)
assert result == "a fake response"
claude.provider.generate.assert_called_once_with("hello", temperature=0.5)
def test_generate_structured_forwards_to_the_real_provider():
claude = Anthropic(api_key="fake-key")
claude.provider = MagicMock()
claude.provider.is_available.return_value = True
claude.provider.generate_structured.return_value = {"key": "value"}
result = claude.generate_structured("hello")
assert result == {"key": "value"}
claude.provider.generate_structured.assert_called_once_with("hello")
def test_generate_typed_forwards_schema_and_max_retries():
claude = Anthropic(api_key="fake-key")
claude.provider = MagicMock()
claude.provider.is_available.return_value = True
fake_schema = object()
claude.provider.generate_typed.return_value = "typed result"
result = claude.generate_typed("hello", fake_schema, max_retries=5)
assert result == "typed result"
claude.provider.generate_typed.assert_called_once_with(
"hello", fake_schema, max_retries=5
)
+12 -1
View File
@@ -24,9 +24,20 @@ import pytest
# rdf:/rdfs: namespaces) and neither the code nor this test caught it,
# since both had the same bug. Importing the real function makes that class
# of drift impossible.
from semantica.explorer.routes.sparql import _is_read_only_query
# fastapi ships in the optional `explorer` extra, not in `dev`, so this import
# fails on a plain dev install. Only the SPARQL class below needs it; the Cypher,
# XXE, vector-serialization and SSRF classes in this module are independent, so
# the skip is scoped to the one class rather than the whole file.
try:
from semantica.explorer.routes.sparql import _is_read_only_query
except ImportError: # pragma: no cover - depends on the installed extras
_is_read_only_query = None
@pytest.mark.skipif(
_is_read_only_query is None,
reason="requires semantica[explorer] (fastapi)",
)
class TestSparqlReadOnlyValidation:
"""Regression tests for SPARQL injection prevention."""
-1
View File
@@ -443,4 +443,3 @@ def test_export_seed_data(seed_manager, temp_data_dir):
rows = list(reader)
assert len(rows) == 1
assert rows[0]["id"] == "1"