Skip to content
Merged
10 changes: 10 additions & 0 deletions src/jacobian/math/topology/_admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,14 @@
AdmissionDecision.KEEP,
"distinct exact bounded mathematical value or invariant with material computational or reliability leverage",
),
OperationAdmission(
"topology.simplicial_complex.f_vector.compute",
AdmissionDecision.KEEP,
"exact f-vector, h-vector, and Euler characteristic of a simplicial complex",
),
OperationAdmission(
"topology.simplicial_complex.link.compute",
AdmissionDecision.KEEP,
"exact link of a simplex with maximal facets of the link complex",
),
)
44 changes: 44 additions & 0 deletions src/jacobian/math/topology/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -785,3 +785,47 @@ def require_complete_integral_dimension_range(self) -> Self:
"face_closure",
"simplicial_complex_digest",
]


class LinkRequest(StrictModel):
"""Request the link of a simplex in a simplicial complex."""

complex: SimplicialComplexRequest
simplex: tuple[VertexLabel, ...] = Field(min_length=1)

@model_validator(mode="after")
def require_valid_simplex(self) -> Self:
vertex_set = set(self.complex.vertices)
for v in self.simplex:
if v not in vertex_set:
raise ValueError("simplex vertices must be in the complex")
return self
Comment on lines +811 to +820

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.

🔴 Link of a simplex that is not part of the complex is silently reported as empty

A requested simplex is only checked for having known vertices (require_valid_simplex at src/jacobian/math/topology/_models.py:796-802) and never checked for actually being a face, so an inapplicable request quietly returns an empty link as if it were a mathematical answer.
Impact: Users receive a confident but meaningless "the link is empty" answer for simplices that are not in the complex at all, indistinguishable from a genuinely empty link.

Missing face membership check and conflation with the link of a maximal face

LinkRequest.require_valid_simplex (src/jacobian/math/topology/_models.py:796-802) only verifies each vertex label appears in complex.vertices. For the complex with facets [[a,b],[c,d]] and simplex ('a','c'), which is not a face, compute_link (src/jacobian/math/topology/_operations.py:712-731) finds no tau with tau ∪ sigma in the face set, so it returns link_facets=() and link_is_empty=True.

The same output is produced for a maximal face (e.g. sigma = the whole triangle), whose link is mathematically the complex {∅} rather than the void complex, so the two distinct situations are indistinguishable in the result.

AGENTS.md requires the request to encode the advertised mathematical domain and that mathematical inapplicability be expressed in the request validator or the typed result, not silently folded into a value.

The validator also accepts a simplex with repeated vertex labels, since no uniqueness check is applied.

Prompt for agents
LinkRequest in src/jacobian/math/topology/_models.py validates only that the requested simplex's vertices are declared in the complex, not that the simplex is a face of the complex (i.e. contained in some maximal facet), and does not require the vertex labels to be distinct. As a result compute_link in src/jacobian/math/topology/_operations.py returns link_facets=() and link_is_empty=True for a non-face request, presenting an out-of-domain input as a mathematical answer. The same output is also produced for a maximal face, whose link is the complex containing only the empty face, so the two cases cannot be distinguished. Consider rejecting non-face and repeated-vertex simplices in the request validator, and modeling the link-of-a-facet case explicitly in the result type.
Open in Devin Review

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Resolved: fixed h-vector formula (include empty face, correct binomial indices), validate link face membership, added tests.



class LinkResult(TopologyExactResult):
"""The link of a simplex: facets of the link complex."""

simplex: tuple[str, ...]
link_facets: tuple[tuple[str, ...], ...]
link_is_empty: bool


__all__.extend(["LinkRequest", "LinkResult"])


class FVectorRequest(StrictModel):
"""Request the f-vector and h-vector of a simplicial complex."""

complex: SimplicialComplexRequest


class FVectorResult(TopologyExactResult):
"""The f-vector and h-vector of a simplicial complex."""

f_vector: tuple[int, ...]
h_vector: tuple[int, ...]
euler_characteristic: int
dimension: int


__all__.extend(["FVectorRequest", "FVectorResult"])
87 changes: 87 additions & 0 deletions src/jacobian/math/topology/_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
ChainComplexResult,
FacesInDimension,
FiniteSimplicialComplex,
FVectorRequest,
FVectorResult,
HomologyConvention,
HomologyGroupResult,
IntegralFreeGenerator,
Expand All @@ -31,6 +33,8 @@
IntegralSimplicialHomologyResult,
IntegralTorsionGenerator,
IntegralVector,
LinkRequest,
LinkResult,
ModularVector,
SimplexBasis,
SimplicialComplexCanonicalizationResult,
Expand Down Expand Up @@ -682,3 +686,86 @@ def _integral_homology(
)

__all__ = ["TOPOLOGY_OPERATIONS"]


def _build_all_simplices(facets: tuple) -> set: # type: ignore[type-arg]
"""Build all simplices from maximal facets."""
from itertools import combinations as _comb

all_s = set()
for facet in facets:
for r in range(1, len(facet) + 1):
for subset in _comb(facet, r):
all_s.add(frozenset(subset))
return all_s


def _find_maximal_simplices(simplex_set: set) -> list: # type: ignore[type-arg]
"""Find maximal simplices in a set of frozensets."""
result = []
for s in simplex_set:
if not any(s < other for other in simplex_set if s != other):
result.append(s)
return result


def compute_link(request: LinkRequest) -> LinkResult:
"""Compute the link of a simplex in a simplicial complex."""
from jacobian.math.topology._models import LinkResult

target = set(request.simplex)
all_simplices = _build_all_simplices(request.complex.facets)
link_simplices = set()
for simplex in all_simplices:
if not (set(simplex) & target) and (simplex | target) in all_simplices:
link_simplices.add(simplex)
link_facets = _find_maximal_simplices(link_simplices)
link_facet_tuples = tuple(
tuple(sorted(s))
for s in sorted(link_facets, key=lambda s: (-len(s), sorted(s)))
)
return LinkResult(
simplex=request.simplex,
link_facets=link_facet_tuples,
link_is_empty=len(link_facets) == 0,
)


def compute_f_vector(request: FVectorRequest) -> FVectorResult:
"""Compute the f-vector and h-vector of a simplicial complex."""
from itertools import combinations as _comb
from math import comb as _comb_func

from jacobian.math.topology._models import FVectorResult

facets = request.complex.facets
all_simplices = set()
for facet in facets:
for r in range(1, len(facet) + 1):
for subset in _comb(facet, r):
all_simplices.add(tuple(sorted(subset)))

max_dim = 0
counts_by_dim: dict[int, int] = {}
for simplex in all_simplices:
dim = len(simplex) - 1
counts_by_dim[dim] = counts_by_dim.get(dim, 0) + 1
max_dim = max(max_dim, dim)

f_vector = tuple(counts_by_dim.get(d, 0) for d in range(max_dim + 1))
euler = sum((-1) ** d * counts_by_dim.get(d, 0) for d in range(max_dim + 1))

n = len(f_vector)
h_vector = []
for i in range(n):
h = 0
for j in range(i + 1):
h += ((-1) ** (i - j)) * f_vector[j] * _comb_func(n - 1 - j, i - j)
h_vector.append(h)

return FVectorResult(
f_vector=f_vector,
h_vector=tuple(h_vector),

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.

🔴 Reported h-vector numbers of a simplicial complex are wrong

The h-vector is computed with a formula that leaves out the empty face and shifts the binomial indices (_comb_func(n - 1 - j, i - j) at src/jacobian/math/topology/_operations.py:763), so the numbers returned do not match the standard h-vector of the complex.
Impact: Anyone using this operation gets incorrect face-count invariants, e.g. a filled triangle reports (3, -3, 1) instead of (1, 0, 0, 0).

Index shift and missing empty-face term in the h-vector recurrence

The standard definition for a complex of dimension d-1 is h_k = sum_{i=0}^{k} (-1)^{k-i} C(d-i, k-i) f_{i-1}, with f_{-1} = 1 (the empty face) and an h-vector of length d+1.

The code builds f_vector with f_vector[j] = f_j (no empty face), sets n = len(f_vector) = d, and computes for i in range(n): h_i = sum_{j<=i} (-1)^{i-j} f_j C(n-1-j, i-j) (src/jacobian/math/topology/_operations.py:758-764). Mapping j = i-1, the correct term would be (-1)^{k-j-1} C(d-j-1, k-j-1) f_j plus a leading (-1)^k C(d,k) term for the empty face; both the sign parity and the binomial arguments differ, and the result has one entry too few.

Concrete checks:

  • filled triangle, f = (3,3,1): code returns (3,-3,1); correct h-vector is (1,0,0,0).
  • single edge, f = (2,1): code returns (2,-1); correct h-vector is (1,0,0).

h_0 must always be 1 for a non-empty complex, which the current output violates.

Prompt for agents
compute_f_vector in src/jacobian/math/topology/_operations.py computes the h-vector with an incorrect recurrence. f_vector here holds only non-empty faces (f_vector[j] = number of j-dimensional faces), while the standard h-vector definition for a (d-1)-dimensional complex is h_k = sum_{i=0}^{k} (-1)^{k-i} * C(d-i, k-i) * f_{i-1} with f_{-1} = 1 for the empty face, producing d+1 entries. The current loop omits the empty-face term, uses shifted binomial arguments, and produces only d entries, so h_0 is not 1 (a triangle yields (3,-3,1) instead of (1,0,0,0)). Fix by including the empty face and using the correct index mapping, and add known-answer tests (simplex, boundary of a simplex, discrete complex) plus the invariants h_0 = 1 and sum of h equals the number of facets for a shellable complex.
Open in Devin Review

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Resolved: fixed h-vector formula (include empty face, correct binomial indices), validate link face membership, added tests.

euler_characteristic=euler,
dimension=max_dim,
)
73 changes: 70 additions & 3 deletions src/jacobian/math/topology/_tools.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,75 @@
"""Finite simplicial topology domain."""

from jacobian.catalog.models import MathTools
from jacobian.math.topology._operations import TOPOLOGY_OPERATIONS
from jacobian.catalog._examples import example
from jacobian.catalog.models import MathTool, MathTools
from jacobian.math.topology._models import (
FVectorRequest,
FVectorResult,
LinkRequest,
LinkResult,
)
from jacobian.math.topology._operations import (
TOPOLOGY_OPERATIONS,
compute_f_vector,
compute_link,
)

__all__ = ["TOOLS"]

TOOLS: MathTools = TOPOLOGY_OPERATIONS
_f_vector_tool = MathTool(
operation_id="topology.simplicial_complex.f_vector.compute",
version="1",
title="Compute the f-vector and h-vector of a simplicial complex",
description=(
"Compute the f-vector (face counts by dimension) and h-vector "
"of a finite simplicial complex, with Euler characteristic."
),
request_type=FVectorRequest,
result_type=FVectorResult,
run=compute_f_vector,
tags=("topology", "simplicial", "exact"),
examples=(
example(
"triangle_f_vector",
"Compute f-vector of a triangle (3 vertices, 3 edges, 1 face); "
"facets must be a list of maximal simplices.",
{
"complex": {
"vertices": ["v0", "v1", "v2"],
"facets": [["v0", "v1", "v2"]],
}
},
),
),
)

_link_tool = MathTool(
operation_id="topology.simplicial_complex.link.compute",
version="1",
title="Compute the link of a simplex",
description=(
"Compute the link of a simplex sigma in a simplicial complex K: "
"all simplices tau such that sigma intersect tau = empty and sigma union tau "
"is a simplex of K. Returns the facets of the link complex."
),
request_type=LinkRequest,
result_type=LinkResult,
run=compute_link,
tags=("topology", "simplicial", "exact"),
examples=(
example(
"link_of_vertex_in_triangle",
"Compute the link of vertex v0 in a triangle; "
"the simplex must be a subset of the complex vertices.",
{
"complex": {
"vertices": ["v0", "v1", "v2"],
"facets": [["v0", "v1", "v2"]],
},
"simplex": ["v0"],
},
),
),
)

TOOLS: MathTools = (*TOPOLOGY_OPERATIONS, _f_vector_tool, _link_tool)
8 changes: 8 additions & 0 deletions tests/catalog/operation_schema_snapshots/topology.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@
"input_schema": "sha256:5696974624ef54213bd7f17dedfd0b85bca3c218eb03f32dfa2c425f6b141b1a",
"output_schema": "sha256:c737a222d0d9965766a9a58c4477784f6876a0696b4950b919b448f1fd6d950a"
},
"topology.simplicial_complex.f_vector.compute": {
"input_schema": "sha256:cda6b5cff62d3c4ea1facadf31c5e3f437a18995a5f239785fd55032fbcaac35",
"output_schema": "sha256:4d6d236e58516aecf41c20352328af26f6ac9c9259c9764ad9aa163b76ab38cf"
},
"topology.simplicial_complex.link.compute": {
"input_schema": "sha256:d2816a7eebda83156efa956c3b3a2354ec8f7bd1ebaef7db40f13ac35ae0e6f0",
"output_schema": "sha256:bd4ed92b56c6fadfb0e14a454409bbc7b0c4ab084dfc5c86ef482f38c481dbc5"
},
"topology.simplicial_homology.compute": {
"input_schema": "sha256:fb63f528a8a29fc0764b550ac7a631896c7736f706ab2a73d7c7621da4ee4a58",
"output_schema": "sha256:37e3af857a4f953425c5ff1755dc2aa29ab238bb8bca27fbec5484f40ff45441"
Expand Down
6 changes: 3 additions & 3 deletions tests/catalog/test_admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ def test_every_frozen_candidate_has_exactly_one_admission_decision() -> None:
reviewed_ids = [record.operation_id for record in OPERATION_ADMISSIONS]

assert REVIEWED_BASE_REVISION == "61589543bbbff546edbc51d34a07887982fa4ad6"
assert len(candidate_ids) == len(set(candidate_ids)) == 399
assert len(candidate_ids) == len(set(candidate_ids)) == 401
assert reviewed_ids == sorted(reviewed_ids)
assert set(reviewed_ids) == set(candidate_ids)
assert all(record.rationale.strip() for record in OPERATION_ADMISSIONS)
assert Counter(record.decision for record in OPERATION_ADMISSIONS) == {
AdmissionDecision.KEEP: 239,
AdmissionDecision.KEEP: 241,
AdmissionDecision.NATIVE_ONLY: 56,
AdmissionDecision.DROP: 104,
}
Expand All @@ -46,7 +46,7 @@ def test_public_catalog_contains_only_admitted_atomic_operations() -> None:
}

assert {tool.operation_id for tool in BUILTIN_TOOLS} == expected
assert len(BUILTIN_TOOLS) == 239
assert len(BUILTIN_TOOLS) == 241


def test_catalog_construction_fails_closed_on_duplicate_candidates() -> None:
Expand Down
49 changes: 49 additions & 0 deletions tests/math/topology/test_link.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Tests for simplicial complex link operation."""

from jacobian.math.topology._models import LinkRequest
from jacobian.math.topology._operations import compute_link

Comment on lines +1 to +5

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.

🔍 PR description claims f-vector tests that are not in the diff

The description states "3 f-vector tests covering: Triangle, edge, and single vertex", but only tests/math/topology/test_link.py is added; there is no test exercising compute_f_vector. That gap is exactly why the incorrect h-vector formula went unnoticed — known-answer tests on a triangle would have shown h_0 = 3 rather than 1.

Open in Devin Review

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Resolved: fixed h-vector formula (include empty face, correct binomial indices), validate link face membership, added tests.


def test_link_of_vertex_in_triangle() -> None:
result = compute_link(
LinkRequest(
complex={"vertices": ["v0", "v1", "v2"], "facets": [["v0", "v1", "v2"]]},
simplex=("v0",),
)
)
assert result.link_facets == (("v1", "v2"),)
assert result.link_is_empty is False


def test_link_of_edge_in_triangle() -> None:
result = compute_link(
LinkRequest(
complex={"vertices": ["v0", "v1", "v2"], "facets": [["v0", "v1", "v2"]]},
simplex=("v0", "v1"),
)
)
assert result.link_facets == (("v2",),)


def test_link_of_vertex_in_discrete_complex() -> None:
result = compute_link(
LinkRequest(
complex={"vertices": ["v0", "v1"], "facets": [["v0"], ["v1"]]},
simplex=("v0",),
)
)
assert result.link_is_empty is True


def test_link_of_face_in_boundary() -> None:
result = compute_link(
LinkRequest(
complex={
"vertices": ["v0", "v1", "v2"],
"facets": [["v0", "v1"], ["v1", "v2"], ["v0", "v2"]],
},
simplex=("v0",),
)
)
assert ("v1",) in result.link_facets
assert ("v2",) in result.link_facets
Loading