From fe9e10a3894b05d63ba0cac0031787bdc3e3bc69 Mon Sep 17 00:00:00 2001 From: Ashut0sh-mishra Date: Tue, 21 Apr 2026 15:53:57 +0530 Subject: [PATCH] feat(graph): add oneOfL/oneOfAL for exactly-one-of-concepts constraint (#371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces oneOfL (and its accumulated sibling oneOfAL) — counting constraints with limitOp == '==' and fixedLimit = 1 that cannot be overridden by a trailing int. Makes `exactly one of these concepts holds` a first-class, intent-revealing LC, directly answering issue #371. atMostL(c1,c2,c3) would permit zero; oneOfL does not. Co-authored-by: nik464 --- domiknows/graph/__init__.py | 4 +- domiknows/graph/logicalConstrain.py | 40 ++++++ .../solver/adaptiveTNormLossCalculator.py | 6 +- test_regr/test_oneofl_between_concepts.py | 134 +++++++++++++++++ test_regr/test_oneofl_stress.py | 135 ++++++++++++++++++ 5 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 test_regr/test_oneofl_between_concepts.py create mode 100644 test_regr/test_oneofl_stress.py diff --git a/domiknows/graph/__init__.py b/domiknows/graph/__init__.py index 02ac1699e..b123676dc 100644 --- a/domiknows/graph/__init__.py +++ b/domiknows/graph/__init__.py @@ -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 diff --git a/domiknows/graph/logicalConstrain.py b/domiknows/graph/logicalConstrain.py index 8a1146f57..738c9620d 100644 --- a/domiknows/graph/logicalConstrain.py +++ b/domiknows/graph/logicalConstrain.py @@ -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): @@ -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): diff --git a/domiknows/solver/adaptiveTNormLossCalculator.py b/domiknows/solver/adaptiveTNormLossCalculator.py index 48e2cede9..661f9053f 100644 --- a/domiknows/solver/adaptiveTNormLossCalculator.py +++ b/domiknows/solver/adaptiveTNormLossCalculator.py @@ -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', @@ -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).""" @@ -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) diff --git a/test_regr/test_oneofl_between_concepts.py b/test_regr/test_oneofl_between_concepts.py new file mode 100644 index 000000000..5fffa88d4 --- /dev/null +++ b/test_regr/test_oneofl_between_concepts.py @@ -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() diff --git a/test_regr/test_oneofl_stress.py b/test_regr/test_oneofl_stress.py new file mode 100644 index 000000000..c5c60205c --- /dev/null +++ b/test_regr/test_oneofl_stress.py @@ -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"