Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/jacobian/catalog/builtins.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@
ADMISSIONS as GRAPHS_ISOMORPHISM_ADMISSIONS,
)
from jacobian.math.graphs.isomorphism._tools import TOOLS as GRAPH_ISOMORPHISM_TOOLS
from jacobian.math.graphs.morphisms._admission import (
ADMISSIONS as GRAPHS_MORPHISMS_ADMISSIONS,
)
from jacobian.math.graphs.morphisms._tools import TOOLS as GRAPHS_MORPHISMS_TOOLS
from jacobian.math.graphs.optimization._admission import (
ADMISSIONS as GRAPHS_OPTIMIZATION_ADMISSIONS,
)
Expand Down Expand Up @@ -279,6 +283,7 @@
*GRAPH_COLORING_OPS_TOOLS,
*GRAPH_SPECTRAL_TOOLS,
*GRAPH_FLOW_TOOLS,
*GRAPHS_MORPHISMS_TOOLS,
*GRAPH_DECOMPOSITION_TOOLS,
*GRAPH_ISOMORPHISM_TOOLS,
*ROOT_ISOLATION_TOOLS,
Expand Down Expand Up @@ -381,6 +386,7 @@
*GRAPHS_DECOMPOSITION_ADMISSIONS,
*GRAPHS_DIRECTED_ADMISSIONS,
*GRAPHS_FLOW_ADMISSIONS,
*GRAPHS_MORPHISMS_ADMISSIONS,
*GRAPHS_ISOMORPHISM_ADMISSIONS,
*GRAPHS_OPTIMIZATION_ADMISSIONS,
*GRAPHS_POLYNOMIALS_ADMISSIONS,
Expand Down
3 changes: 3 additions & 0 deletions src/jacobian/math/graphs/morphisms/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Graph morphism operations."""

__all__: list[str] = []
28 changes: 28 additions & 0 deletions src/jacobian/math/graphs/morphisms/_admission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Owner-local admission decisions for built-in math operations."""

from __future__ import annotations

from jacobian.catalog.admission import AdmissionDecision, OperationAdmission

ADMISSIONS: tuple[OperationAdmission, ...] = (
OperationAdmission(
"graph.core.check",
AdmissionDecision.KEEP,
"distinct exact bounded mathematical value or invariant with material computational or reliability leverage",
),
OperationAdmission(
"graph.homomorphism.check",
AdmissionDecision.KEEP,
"distinct exact bounded mathematical value or invariant with material computational or reliability leverage",
),
OperationAdmission(
"graph.homomorphism.find",
AdmissionDecision.KEEP,
"distinct exact or explicitly bounded search outcome with material computational leverage",
),
OperationAdmission(
"graph.retraction.check",
AdmissionDecision.KEEP,
"distinct exact or explicitly bounded search outcome with material computational leverage",
),
)
93 changes: 93 additions & 0 deletions src/jacobian/math/graphs/morphisms/_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Typed wire contracts for graph morphism operations."""

from __future__ import annotations

from typing import Self

from pydantic import Field, model_validator

from jacobian._models import StrictModel

MAX_VERTICES = 64
MAX_EDGES = 512


class SimpleGraph(StrictModel):
"""A simple undirected graph with integer-labelled vertices."""

vertex_count: int = Field(ge=1, le=MAX_VERTICES)
edges: tuple[tuple[int, int], ...] = Field(default=(), max_length=MAX_EDGES)

@model_validator(mode="after")
def require_valid(self) -> Self:
seen: set[tuple[int, int]] = set()
for u, v in self.edges:
if not (0 <= u < self.vertex_count and 0 <= v < self.vertex_count):
raise ValueError("edge vertices must be in 0..vertex_count-1")
if u == v:
raise ValueError("self-loops are not allowed")
endpoint_pair = (min(u, v), max(u, v))
if endpoint_pair in seen:
raise ValueError("edges must be unique")
seen.add(endpoint_pair)
return self


class HomomorphismCheckRequest(StrictModel):
source_graph: SimpleGraph
target_graph: SimpleGraph
vertex_map: tuple[int, ...]

@model_validator(mode="after")
def require_valid(self) -> Self:
if len(self.vertex_map) != self.source_graph.vertex_count:
raise ValueError("vertex_map length must match source_graph vertex_count")
if any(not 0 <= v < self.target_graph.vertex_count for v in self.vertex_map):
raise ValueError("vertex_map entries must be valid target_graph vertices")
return self


class HomomorphismFindRequest(StrictModel):
source_graph: SimpleGraph
target_graph: SimpleGraph


class CoreCheckRequest(StrictModel):
graph: SimpleGraph


class RetractionCheckRequest(StrictModel):
graph: SimpleGraph
subgraph_vertices: tuple[int, ...]

@model_validator(mode="after")
def require_valid(self) -> Self:
if len(self.subgraph_vertices) > self.graph.vertex_count:
raise ValueError("subgraph_vertices must be a subset")
for v in self.subgraph_vertices:
if not 0 <= v < self.graph.vertex_count:
raise ValueError("subgraph_vertices must be valid vertex indices")
if len(set(self.subgraph_vertices)) != len(self.subgraph_vertices):
raise ValueError("subgraph_vertices must be unique")
return self


class HomomorphismCheckResult(StrictModel):
is_homomorphism: bool
method: str = "EDGE_PRESERVING_CHECK"


class HomomorphismFindResult(StrictModel):
found: bool
vertex_map: tuple[int, ...] = ()
method: str = "BACKTRACKING_SEARCH"


class CoreCheckResult(StrictModel):
is_core: bool
method: str = "ENDOMORPHISM_CHECK"


class RetractionCheckResult(StrictModel):
is_retraction: bool
method: str = "HOMOMORPHISM_CHECK"
169 changes: 169 additions & 0 deletions src/jacobian/math/graphs/morphisms/_operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""Domain functions for graph morphism operations."""

from __future__ import annotations

from jacobian.math.graphs.morphisms._models import (
CoreCheckRequest,
CoreCheckResult,
HomomorphismCheckRequest,
HomomorphismCheckResult,
HomomorphismFindRequest,
HomomorphismFindResult,
RetractionCheckRequest,
RetractionCheckResult,
)


def _adjacency(graph_edges: tuple[tuple[int, int], ...]) -> set[tuple[int, int]]:
"""Return a set of all directed edges (both directions)."""
adj: set[tuple[int, int]] = set()
for u, v in graph_edges:
adj.add((u, v))
adj.add((v, u))
return adj


def _is_homomorphism(
source_edges: tuple[tuple[int, int], ...],
target_edges: tuple[tuple[int, int], ...],
vertex_map: list[int],
) -> bool:
target_adj = _adjacency(target_edges)
return all((vertex_map[u], vertex_map[v]) in target_adj for u, v in source_edges)


def compute_homomorphism_check(
request: HomomorphismCheckRequest,
) -> HomomorphismCheckResult:
is_h = _is_homomorphism(
request.source_graph.edges,
request.target_graph.edges,
list(request.vertex_map),
)
return HomomorphismCheckResult(is_homomorphism=is_h)


def compute_homomorphism_find(
request: HomomorphismFindRequest,
) -> HomomorphismFindResult:
source = request.source_graph
target = request.target_graph
target_adj = _adjacency(target.edges)

vertex_map: list[int] = [-1] * source.vertex_count

def backtrack(pos: int) -> bool:
if pos == source.vertex_count:
return True
for candidate in range(target.vertex_count):
vertex_map[pos] = candidate
ok = True
for u, v in source.edges:
if (
u == pos
and vertex_map[v] != -1
and (vertex_map[u], vertex_map[v]) not in target_adj
):
ok = False
break
if (
v == pos
and vertex_map[u] != -1
and (vertex_map[u], vertex_map[v]) not in target_adj
):
ok = False
break
if ok and backtrack(pos + 1):
return True
vertex_map[pos] = -1
return False

found = backtrack(0)
return HomomorphismFindResult(
found=found,
vertex_map=tuple(vertex_map) if found else (),
)
Comment on lines +55 to +85

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Homomorphism search and retraction check can run essentially forever on accepted inputs

The homomorphism search explores all vertex assignments with no work budget (backtrack(0) at src/jacobian/math/graphs/morphisms/_operations.py:81) while the request permits up to 64 vertices, so ordinary accepted inputs make the call hang indefinitely instead of returning a bounded answer.
Impact: A single request can occupy the server for hours or longer with no result, blocking the caller.

Exponential search space accepted by the request contract

SimpleGraph accepts up to 64 vertices and 512 edges (src/jacobian/math/graphs/morphisms/_models.py:11-19) and HomomorphismFindRequest places no further bound. compute_homomorphism_find performs plain backtracking over target.vertex_count ** source.vertex_count assignments with only local edge pruning; negative instances force (near) full exploration.

Measured with the exact algorithm: odd cycle C_n → K_{4,4} (no homomorphism exists) takes 0.05 s for n=7, 0.72 s for n=9, i.e. roughly ×14 per two extra vertices, so n=15 is already ~30 minutes and n=64 is unreachable. compute_retraction_check (src/jacobian/math/graphs/morphisms/_operations.py:141-168) has the same unbounded structure over the non-fixed vertices, as does the core search once its pruning bug is fixed.

AGENTS.md requires that accepted input, algorithmic work, and result all be separately bounded and that the request be narrowed (or the operation not exposed) when the algorithm cannot exhaust the advertised domain. Options: shrink MAX_VERTICES for these operations to a size the search can exhaust, or add an explicit tested work budget with a typed incomplete/unknown outcome in the result model.

Prompt for agents
The morphism search operations accept graphs of up to 64 vertices and 512 edges (see MAX_VERTICES/MAX_EDGES in src/jacobian/math/graphs/morphisms/_models.py) but implement plain exhaustive backtracking (compute_homomorphism_find, compute_retraction_check, and compute_core_check in src/jacobian/math/graphs/morphisms/_operations.py). Negative instances (for example an odd cycle mapped into a bipartite target) force near-complete exploration of an exponential search space; measurements of the same algorithm show roughly a 14x cost increase per two additional source vertices, so requests well inside the advertised domain never terminate in practical time. AGENTS.md requires the accepted input, the algorithmic work, and the exact result to be separately bounded, and requires narrowing the request or changing the typed result when the algorithm cannot exhaust the advertised domain. Consider either introducing a much smaller, explicitly tested vertex bound dedicated to these search operations, or adding an explicit and tested search-step budget whose exhaustion is reported as a typed incomplete/unknown outcome in the result models (not as a mathematical conclusion). Alternatively, use a maintained backend (e.g. NetworkX/Z3) as the engine as the repository guide prefers. Add boundary and adversarial tests covering the worst-case instances.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.



def _is_endomorphism(
source_edges: tuple[tuple[int, int], ...],
source_adj: set[tuple[int, int]],
mapping: list[int],
) -> bool:
return all((mapping[u], mapping[v]) in source_adj for u, v in source_edges)


def compute_core_check(request: CoreCheckRequest) -> CoreCheckResult:
"""A graph is a core iff it has no non-injective endomorphism."""
source = request.graph
source_adj = _adjacency(source.edges)
vertex_map: list[int] = [-1] * source.vertex_count
has_non_injective = [False]

def search_non_injective(pos: int) -> bool:
if pos == source.vertex_count:
used = set(vertex_map)
if len(used) < source.vertex_count:
has_non_injective[0] = True
return True
return False
for candidate in range(source.vertex_count):
vertex_map[pos] = candidate
if _is_endomorphism(
source.edges, source_adj, vertex_map
) and search_non_injective(pos + 1):
return True
vertex_map[pos] = -1
return False
Comment on lines +103 to +117

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Core check wrongly reports every graph with at least one edge as a core

The search for a collapsing self-map rejects every partially built map (_is_endomorphism(source.edges, source_adj, vertex_map) at src/jacobian/math/graphs/morphisms/_operations.py:112-114) because unassigned vertices are still placeholders, so any graph that has an edge is declared a core.
Impact: The operation returns a mathematically wrong answer for almost all graphs, e.g. a 3-vertex path or a 4-cycle (both not cores) are reported as cores.

Premature pruning by validating the full edge list against a partially filled map

search_non_injective assigns one vertex at a time, but validation is done over all source edges via _is_endomorphism (src/jacobian/math/graphs/morphisms/_operations.py:88-93). While pos < vertex_count, the trailing entries of vertex_map are still -1, so any edge touching an unassigned vertex yields the pair (-1, x), which is never in source_adj. Consequently the very first candidate at pos = 0 fails for any graph with an edge, the search returns False immediately, and is_core=not found becomes True.

Simulation of the exact algorithm: P3 (edges = [(0,1),(1,2)]) → is_core=True (should be False, P3 retracts onto an edge); C4 → is_core=True (should be False). Only the edgeless case (tests/math/graph_morphisms/test_graph_morphisms.py:73-76) exercises the non-core branch, so the test suite does not catch it, and the K2 test passes for the wrong reason.

The fix is to validate only edges whose both endpoints are already assigned (the same partial-consistency pattern used in compute_homomorphism_find at src/jacobian/math/graphs/morphisms/_operations.py:61-75).

Suggested change
def search_non_injective(pos: int) -> bool:
if pos == source.vertex_count:
used = set(vertex_map)
if len(used) < source.vertex_count:
has_non_injective[0] = True
return True
return False
for candidate in range(source.vertex_count):
vertex_map[pos] = candidate
if _is_endomorphism(
source.edges, source_adj, vertex_map
) and search_non_injective(pos + 1):
return True
vertex_map[pos] = -1
return False
def search_non_injective(pos: int) -> bool:
if pos == source.vertex_count:
used = set(vertex_map)
if len(used) < source.vertex_count:
has_non_injective[0] = True
return True
return False
for candidate in range(source.vertex_count):
vertex_map[pos] = candidate
assigned_edges = tuple(
(u, v)
for u, v in source.edges
if vertex_map[u] != -1 and vertex_map[v] != -1
)
if _is_endomorphism(
assigned_edges, source_adj, vertex_map
) and search_non_injective(pos + 1):
return True
vertex_map[pos] = -1
return False
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


found = search_non_injective(0)
return CoreCheckResult(is_core=not found)


def compute_retraction_check(
request: RetractionCheckRequest,
) -> RetractionCheckResult:
"""Check if a retraction onto an induced subgraph exists."""
source = request.graph
subgraph = set(request.subgraph_vertices)

target_edges = [(u, v) for u, v in source.edges if u in subgraph and v in subgraph]
target_adj = _adjacency(tuple(target_edges))

vertex_map: list[int] = [-1] * source.vertex_count
subgraph_list = list(subgraph)

for v in subgraph_list:
vertex_map[v] = v

remaining = [i for i in range(source.vertex_count) if i not in subgraph]

def backtrack(pos: int) -> bool:
if pos == len(remaining):
return True
v = remaining[pos]
for candidate in subgraph_list:
vertex_map[v] = candidate
ok = True
for u, w in source.edges:
if (
u == v
and vertex_map[w] != -1
and (candidate, vertex_map[w]) not in target_adj
):
ok = False
break
if (
w == v
and vertex_map[u] != -1
and (vertex_map[u], candidate) not in target_adj
):
ok = False
break
if ok and backtrack(pos + 1):
return True
vertex_map[v] = -1
return False

found = backtrack(0)
return RetractionCheckResult(is_retraction=found)
Loading