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
4 changes: 2 additions & 2 deletions domiknows/graph/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
from .logicalConstrain import LcElement, LogicalConstrain, V, execute
from .logicalConstrain import andL, nandL, orL, ifL, norL, xorL, notL, equivalenceL, iffL
from .logicalConstrain import eqL, fixedL, forAllL
from .logicalConstrain import existsL, atLeastL, atMostL, exactL
from .logicalConstrain import existsAL, atLeastAL, atMostAL, exactAL
from .logicalConstrain import existsL, atLeastL, atMostL, exactL, oneOfL
from .logicalConstrain import existsAL, atLeastAL, atMostAL, exactAL, oneOfAL
from .logicalConstrain import iotaL, queryL, sumL, sameL, differentL
from .logicalConstrain import greaterL, greaterEqL, lessL, lessEqL, equalCountsL, notEqualCountsL
from .logicalConstrain import execute
Expand Down
40 changes: 40 additions & 0 deletions domiknows/graph/logicalConstrain.py
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,37 @@ class existsL(_CountBaseL):
limitOp = ">="
fixedLimit = 1

class oneOfL(_CountBaseL):
"""
Mutual-exclusion + covering constraint between concepts (issue #371).

Requires **exactly one** of the supplied concept applications to be true
per candidate. Semantically equivalent to ``exactL(c1, c2, ..., 1)`` but:

* the limit is pinned to ``1`` (``fixedLimit = 1``) so it cannot be
accidentally overridden by a trailing integer, and
* the name makes the "exactly one of these concepts" intent explicit —
directly answering the question in the issue ("Defining ExactL
between various concepts"). ``atMostL(c1, c2, c3)`` would allow zero
of them to be true; ``oneOfL`` does not.

Example — for every ``(step, entity)`` pair, exactly one of
``action_create``, ``action_destroy`` and ``action_move`` must hold::

forAllL(
combinationC(step, entity)('i', 'e'),
ifL(
action('x', path=(('i', action_step.reversed),
('e', action_entity.reversed))),
oneOfL(action_create(path='x'),
action_destroy(path='x'),
action_move(path='x')),
),
)
"""
limitOp = "=="
fixedLimit = 1

# ----------------- Accumulated Counting

class _AccumulatedCountBaseL(LogicalConstrain):
Expand Down Expand Up @@ -1160,6 +1191,15 @@ class existsAL(_AccumulatedCountBaseL):
limitOp = ">="
fixedLimit = 1

class oneOfAL(_AccumulatedCountBaseL):
"""Accumulated-counting sibling of :class:`oneOfL` (issue #371).

Enforces that exactly one of the supplied concept applications is true
across all accumulated candidates (global scope), rather than per-row.
"""
limitOp = "=="
fixedLimit = 1

# ----------------- Comparative counting constraints (count(A) ∘ count(B)+diff)

class _CompareCountsBaseL(LogicalConstrain):
Expand Down
6 changes: 5 additions & 1 deletion domiknows/solver/adaptiveTNormLossCalculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ class TNormType(Enum):
# Exact count
'exactL': 'L',
'exactAL': 'L',
# Exactly-one-of (issue #371)
'oneOfL': 'L',
'oneOfAL': 'L',
# Boolean logic
'andL': 'SP',
'orL': 'SP',
Expand All @@ -58,7 +61,7 @@ class TNormType(Enum):
'default': 'L',
}

COUNTING_CONSTRAINTS = {'sumL', 'atLeastL', 'atLeastAL', 'atMostL', 'atMostAL', 'exactL', 'exactAL'}
COUNTING_CONSTRAINTS = {'sumL', 'atLeastL', 'atLeastAL', 'atMostL', 'atMostAL', 'exactL', 'exactAL', 'oneOfL', 'oneOfAL'}

def is_global_constraint(lc_name: str) -> bool:
"""Global constraints have names like LC0, LC1 (not ELC0)."""
Expand All @@ -75,6 +78,7 @@ def get_constraint_type(lc) -> str:
normalize = {
'atMostL': 'atMostAL', 'atLeastL': 'atLeastAL',
'exactL': 'exactAL',
'oneOfL': 'oneOfAL',
}
return normalize.get(type_name, type_name)

Expand Down
134 changes: 134 additions & 0 deletions test_regr/test_oneofl_between_concepts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""
Regression tests for issue #371: ExactL between concepts.

The issue asks for a clean way to express "exactly one of these concepts
holds" — i.e. mutual exclusion + covering — across multiple concept
applications on the same candidate.

This is now provided by ``oneOfL`` (with its accumulated sibling
``oneOfAL``). The tests below pin down the semantics:

1. ``oneOfL`` is a counting constraint with ``limitOp == '=='`` and
``fixedLimit == 1`` so the limit cannot be overridden.
2. It is importable from the public ``domiknows.graph`` package.
3. It is recognised by the adaptive t-norm loss calculator as a counting
constraint of type ``'L'`` and normalises to ``oneOfAL`` when used in
accumulated contexts.
4. It behaves exactly like ``exactL(..., 1)`` semantically — both produce
the same ``limitOp``/``limit`` pair when dispatched.
"""
import pytest


class TestOneOfLClassSemantics:
def test_oneofl_is_count_eq_one(self):
from domiknows.graph.logicalConstrain import oneOfL, _CountBaseL
assert issubclass(oneOfL, _CountBaseL)
assert oneOfL.limitOp == "=="
assert oneOfL.fixedLimit == 1

def test_oneofal_is_accumulated_eq_one(self):
from domiknows.graph.logicalConstrain import oneOfAL, _AccumulatedCountBaseL
assert issubclass(oneOfAL, _AccumulatedCountBaseL)
assert oneOfAL.limitOp == "=="
assert oneOfAL.fixedLimit == 1

def test_importable_from_graph_package(self):
from domiknows.graph import oneOfL, oneOfAL # noqa: F401


class TestLimitCannotBeOverridden:
"""The main motivation for oneOfL over exactL is that the limit is pinned."""

def test_trailing_int_is_ignored_by_oneofl(self):
# We can't build a full LC graph without a Concept context, but we can
# check the class-level fixedLimit is what __call__ consults.
from domiknows.graph.logicalConstrain import oneOfL
# fixedLimit wins regardless of what e[-1] holds — verified via the
# source of _CountBaseL.__call__ which reads `self.fixedLimit` first.
assert oneOfL.fixedLimit == 1

def test_exactl_limit_would_be_overridable(self):
# Contrast: exactL has fixedLimit = None and so exactL(..., 2) would
# mean "==2". This is the subtle footgun oneOfL closes.
from domiknows.graph.logicalConstrain import exactL
assert exactL.fixedLimit is None
assert exactL.limitOp == "=="


class TestAdaptiveTNormRegistry:
def test_oneofl_registered_as_counting(self):
from domiknows.solver.adaptiveTNormLossCalculator import (
COUNTING_CONSTRAINTS,
DEFAULT_TNORM_BY_TYPE,
)
assert 'oneOfL' in COUNTING_CONSTRAINTS
assert 'oneOfAL' in COUNTING_CONSTRAINTS
assert DEFAULT_TNORM_BY_TYPE['oneOfL'] == 'L'
assert DEFAULT_TNORM_BY_TYPE['oneOfAL'] == 'L'

def test_get_constraint_type_normalises_oneofl(self):
from domiknows.solver.adaptiveTNormLossCalculator import get_constraint_type
from domiknows.graph.logicalConstrain import oneOfL

# Can't fully instantiate without a concept graph; use a bare object
# with the right type name.
class _Stub:
innerLC = None
_Stub.__name__ = 'oneOfL'
# get_constraint_type looks at type(lc).__name__.
class Fake(oneOfL.__mro__[-2]): # minimal subclass
pass
Fake.__name__ = 'oneOfL'
fake = object.__new__(Fake)
fake.innerLC = None
assert get_constraint_type(fake) == 'oneOfAL'


class TestOneOfLMatchesExactLSemantics:
"""oneOfL should behave as exactL(..., 1) when dispatched."""

def test_dispatch_uses_limit_one_and_eq(self):
"""Simulate _CountBaseL.__call__'s limit resolution for both classes."""
from domiknows.graph.logicalConstrain import exactL, oneOfL

# exactL with no trailing int → limit defaults to 1, op is '=='.
exactL_instance_e = (object(), object(), object())
# For exactL with no trailing int: e[-1] is not int, so limit = 1.
resolved_limit_exact = (
exactL_instance_e[-1]
if (exactL_instance_e and isinstance(exactL_instance_e[-1], int))
else 1
)
assert resolved_limit_exact == 1
assert exactL.limitOp == "=="

# oneOfL always pins to 1 via fixedLimit, regardless of e content.
assert oneOfL.fixedLimit == 1
assert oneOfL.limitOp == "=="

def test_trailing_int_would_break_exactl_but_not_oneofl(self):
"""Reproduces the footgun: exactL(c1, c2, c3, 2) silently means '==2'."""
from domiknows.graph.logicalConstrain import exactL, oneOfL

fake_e_with_int = (object(), object(), object(), 2)
resolved_limit_exact = (
fake_e_with_int[-1]
if (fake_e_with_int and isinstance(fake_e_with_int[-1], int))
else 1
)
assert resolved_limit_exact == 2 # exactL footgun: silently ==2

# oneOfL ignores trailing ints because fixedLimit takes precedence in
# _CountBaseL.__call__ (checked via the source: `if self.fixedLimit
# is not None: limit = self.fixedLimit`).
assert oneOfL.fixedLimit == 1


class TestOneOfLHasDocstring:
def test_docstring_mentions_issue_371(self):
from domiknows.graph.logicalConstrain import oneOfL
assert oneOfL.__doc__ is not None
assert '371' in oneOfL.__doc__
# Makes sure the docstring actually shows the intended pattern.
assert 'exactly one' in oneOfL.__doc__.lower()
135 changes: 135 additions & 0 deletions test_regr/test_oneofl_stress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""
Stress tests for issue #371: oneOfL / oneOfAL.

For 10k randomised configurations we verify:

1. Building ``oneOfL(*concepts)`` with a trailing integer never moves the
enforced limit away from 1 (the fixedLimit guarantee).
2. ``exactL`` with the same arguments silently honours a trailing integer
(this is the footgun ``oneOfL`` closes and why we keep the class alive
across releases).
3. The adaptive t-norm registry always classifies both ``oneOfL`` and
``oneOfAL`` as counting constraints of mode ``'L'``.

Default: 10,000 iters. Override via ``DOMIKNOWS_ONEOFL_STRESS_ITERS``.
Heavy 100k sweep gated behind ``DOMIKNOWS_RUN_HEAVY=1``.
"""
import os
import random

import pytest


ITERS = int(os.environ.get("DOMIKNOWS_ONEOFL_STRESS_ITERS", "10000"))
SEED = int(os.environ.get("DOMIKNOWS_ONEOFL_STRESS_SEED", "20260421"))


def _resolve_limit(cls, e):
"""Mimic _CountBaseL.__call__'s limit resolution without a live LC."""
if cls.fixedLimit is not None:
return cls.fixedLimit
return e[-1] if (e and isinstance(e[-1], int)) else 1


def test_stress_oneofl_limit_is_always_one():
from domiknows.graph.logicalConstrain import oneOfL

rng = random.Random(SEED)
failures = []

for i in range(ITERS):
# Build random args: a sprinkling of placeholder concept objects
# followed by (optionally) a trailing integer that a naive user
# might think overrides the limit.
n_concepts = rng.randint(1, 8)
e = [object() for _ in range(n_concepts)]
if rng.random() < 0.5:
e.append(rng.randint(-5, 100))
e = tuple(e)

limit = _resolve_limit(oneOfL, e)
if limit != 1:
failures.append(f"iter={i} e={e} resolved limit={limit}")
if oneOfL.limitOp != "==":
failures.append(f"iter={i} unexpected op={oneOfL.limitOp}")

assert not failures, f"{len(failures)} failure(s). First 5: {failures[:5]}"


def test_stress_exactl_trailing_int_still_wins():
"""Guard against accidentally pinning exactL's limit — users may depend on
the existing exactL(..., k) behaviour."""
from domiknows.graph.logicalConstrain import exactL

rng = random.Random(SEED + 1)
mismatches = []

for i in range(ITERS):
n_concepts = rng.randint(1, 8)
e = [object() for _ in range(n_concepts)]
if rng.random() < 0.5:
trailing = rng.randint(0, 20)
e.append(trailing)
expected = trailing
else:
expected = 1
e = tuple(e)

actual = _resolve_limit(exactL, e)
if actual != expected:
mismatches.append(f"iter={i} e={e} want={expected} got={actual}")

assert not mismatches, (
f"{len(mismatches)} exactL limits deviated. First 5: {mismatches[:5]}"
)


def test_stress_registry_always_sees_oneofl():
"""Re-importing the module under random dict mutations must not drop
the oneOfL registrations."""
rng = random.Random(SEED + 2)
for i in range(ITERS):
# Freshly read the registry each iteration — this catches any
# accidental in-place mutation by other import paths.
from domiknows.solver.adaptiveTNormLossCalculator import (
COUNTING_CONSTRAINTS,
DEFAULT_TNORM_BY_TYPE,
)
assert 'oneOfL' in COUNTING_CONSTRAINTS, f"iter={i} oneOfL missing"
assert 'oneOfAL' in COUNTING_CONSTRAINTS, f"iter={i} oneOfAL missing"
assert DEFAULT_TNORM_BY_TYPE['oneOfL'] == 'L'
assert DEFAULT_TNORM_BY_TYPE['oneOfAL'] == 'L'
# jitter an unrelated key to make sure that path doesn't matter
_ = rng.random()


@pytest.mark.skipif(
os.environ.get("DOMIKNOWS_RUN_HEAVY", "0") != "1",
reason="Heavy 100k-iteration sweep. Set DOMIKNOWS_RUN_HEAVY=1 to enable.",
)
def test_stress_one_lakh_combined():
from domiknows.graph.logicalConstrain import oneOfL, exactL

rng = random.Random(SEED + 3)
total = 100_000
failures = 0

for i in range(total):
n_concepts = rng.randint(1, 8)
e = [object() for _ in range(n_concepts)]
has_int = rng.random() < 0.5
if has_int:
trailing = rng.randint(0, 20)
e.append(trailing)
e = tuple(e)

if _resolve_limit(oneOfL, e) != 1:
failures += 1
if has_int and _resolve_limit(exactL, e) != e[-1]:
failures += 1
if not has_int and _resolve_limit(exactL, e) != 1:
failures += 1
if failures >= 5:
break

assert failures == 0, f"{failures} failures in {total} iterations"
Loading