blob: 38f3d04ddd4daaf504cb190535b34bbc41f76f29 [file] [edit]
# Copyright 2026 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
"""Tests for the solver."""
import json
import random
import unittest
from ortools.sat.python import cp_model
from pw_coverage.config_solver.model import (
CoverageGoal,
Exclusion,
ExistingCoverage,
Option,
Parameter,
PointConstraint,
TestModel,
)
from pw_coverage.config_solver.solver import (
CoverageSolver,
SolverResult,
solve_model,
)
class TestSolver(unittest.TestCase):
"""Tests for the solver."""
def test_explicit_coverage(self) -> None:
# 2 Parameters, 2 Options each.
# Goal: Only cover parameter A (1-way).
# Should pick A1 and A2. B can be anything.
# Minimal solution size is 2 (e.g. {A1, B1}, {A2, B1}).
# If pairwise was implicit, it would require 4.
p_a = Parameter("A", [Option("1"), Option("2")])
p_b = Parameter("B", [Option("1"), Option("2")])
model = TestModel([p_a, p_b], coverage_goals=[CoverageGoal(["A"])])
result = solve_model(model, verbose=False)
self.assertTrue(result.is_feasible)
self.assertEqual(len(result.selected_configurations), 2)
# Verify A is fully covered
a_vals = {r["A"] for r in result.selected_configurations}
self.assertEqual(a_vals, {"1", "2"})
def test_simple_pairwise(self) -> None:
# 2 Parameters, 2 Options each.
# Explicitly ask for Pairwise.
p_a = Parameter("A", [Option("1"), Option("2")])
p_b = Parameter("B", [Option("1"), Option("2")])
model = TestModel([p_a, p_b], coverage_goals=[CoverageGoal(["A", "B"])])
result = solve_model(model, verbose=False)
self.assertTrue(result.is_feasible)
# Should select all 4 because we need (A1, B1), (A1, B2), (A2, B1),
# (A2, B2)
self.assertEqual(len(result.selected_configurations), 4)
def test_cost_minimization(self) -> None:
# A1 is cheap, A2 is expensive.
# B1 is cheap.
# We need (A1, B1) and (A2, B1).
p_a = Parameter("A", [Option("1", cost=1), Option("2", cost=100)])
p_b = Parameter("B", [Option("1", cost=1)])
# Explicit goal: Cover A x B
model = TestModel([p_a, p_b], coverage_goals=[CoverageGoal(["A", "B"])])
result = solve_model(model, verbose=False)
self.assertEqual(len(result.selected_configurations), 2)
costs = sum(
sum(
o.cost
for p in model.parameters
for o in p.options
if o.value == r[p.name]
)
for r in result.selected_configurations
)
# Cost should be (1+1) + (100+1) = 103
self.assertEqual(costs, 103)
def test_unsatisfiable_model(self) -> None:
# A model where NO configuration is valid.
p_a = Parameter("A", [Option("1")])
# Constraint: IF A=1 THEN A!=1 (Impossible)
ex = Exclusion({"A": "1"}, {"A": {"1"}})
# Goal: Cover A
model = TestModel(
[p_a], constraints=[ex], coverage_goals=[CoverageGoal(["A"])]
)
result = solve_model(model, verbose=False)
self.assertFalse(result.is_feasible)
def test_unsatisfiable_model_verbose(self) -> None:
"""Ensure verbose logging doesn't crash on failure."""
p_a = Parameter("A", [Option("1")])
ex = Exclusion({"A": "1"}, {"A": {"1"}})
model = TestModel(
[p_a], constraints=[ex], coverage_goals=[CoverageGoal(["A"])]
)
# This will print error messages to stdout, which we don't strictly
# assert on, but we ensure it doesn't raise an exception.
result = solve_model(model, verbose=True)
self.assertFalse(result.is_feasible)
def test_uncoverable_pair_handled_gracefully(self) -> None:
# A, B. (A1, B1) is invalid.
# Solver should pick enough configs to cover (A1, B2), (A2, B1),
# (A2, B2). It should NOT fail just because (A1, B1) is impossible.
p_a = Parameter("A", [Option("1"), Option("2")])
p_b = Parameter("B", [Option("1"), Option("2")])
ex = Exclusion({"A": "1"}, {"B": {"1"}})
model = TestModel(
[p_a, p_b], [ex], coverage_goals=[CoverageGoal(["A", "B"])]
)
result = solve_model(model, verbose=False)
self.assertTrue(result.is_feasible)
# Verify (A1, B1) is NOT in result
for r in result.selected_configurations:
self.assertFalse(r["A"] == "1" and r["B"] == "1")
# Verify valid pairs ARE covered
covered = set()
for r in result.selected_configurations:
covered.add((r["A"], r["B"]))
self.assertIn(("1", "2"), covered)
self.assertIn(("2", "1"), covered)
self.assertIn(("2", "2"), covered)
def test_optimization_avoids_expensive_redundant_config(self) -> None:
# 3 Parameters: A, B, C with options 1, 2.
# Config {A1, B1, C1} is extremely expensive.
# Its pairs (A1, B1), (A1, C1), (B1, C1) can be covered by other
# configs:
# - (A1, B1) covered by {A1, B1, C2}
# - (A1, C1) covered by {A1, B2, C1}
# - (B1, C1) covered by {A2, B1, C1}
p_a = Parameter("A", [Option("1"), Option("2")])
p_b = Parameter("B", [Option("1"), Option("2")])
p_c = Parameter("C", [Option("1"), Option("2")])
# Explicit Full 2-way coverage goal
model = TestModel(
[p_a, p_b, p_c],
coverage_goals=[
CoverageGoal(["A", "B"]),
CoverageGoal(["A", "C"]),
CoverageGoal(["B", "C"]),
],
)
result = solve_model(model, verbose=False)
self.assertTrue(result.is_feasible)
# Cartesian is 8. Minimal covering array is 4.
# Our solver should find 4.
self.assertEqual(len(result.selected_configurations), 4)
def test_optimization_with_costs(self) -> None:
# A1 cost 100, A2 cost 1.
# We want to minimize occurrences of A1 while still covering validation.
# 3 params x 2 opts.
# We need 4 configs total.
p_a = Parameter("A", [Option("1", cost=100), Option("2", cost=1)])
p_b = Parameter("B", [Option("1", cost=1), Option("2", cost=1)])
p_c = Parameter("C", [Option("1", cost=1), Option("2", cost=1)])
model = TestModel(
[p_a, p_b, p_c],
coverage_goals=[
CoverageGoal(["A", "B"]),
CoverageGoal(["A", "C"]),
CoverageGoal(["B", "C"]),
],
)
result = solve_model(model, verbose=False)
self.assertTrue(result.is_feasible)
# Count occurrences of A1
a1_count = sum(
1 for r in result.selected_configurations if r["A"] == "1"
)
self.assertEqual(a1_count, 2)
def test_existing_coverage_reduce_solution_size(self) -> None:
# 2 Parameters, 2 Options each.
p_a = Parameter("A", [Option("1"), Option("2")])
p_b = Parameter("B", [Option("1"), Option("2")])
# Explicit Full 2-way coverage goal (needs 4 configs)
# We provide 2 existing configs covering (A1, B1) and (A2, B2)
model = TestModel(
[p_a, p_b],
coverage_goals=[CoverageGoal(["A", "B"])],
existing_coverage=[{"A": "1", "B": "1"}, {"A": "2", "B": "2"}],
)
result = solve_model(model, verbose=False)
self.assertTrue(result.is_feasible)
# We need to cover the remaining: (A1, B2) and (A2, B1).
# Should be exactly 2 new configs.
self.assertEqual(len(result.selected_configurations), 2)
# Verify the new configs cover the gaps
new_interactions = set(
(r["A"], r["B"]) for r in result.selected_configurations
)
self.assertIn(("1", "2"), new_interactions)
self.assertIn(("2", "1"), new_interactions)
def test_existing_coverage_full(self) -> None:
# Provide all 4 configs as existing coverage. Solver should return
# empty list.
p_a = Parameter("A", [Option("1"), Option("2")])
p_b = Parameter("B", [Option("1"), Option("2")])
existing = [
{"A": "1", "B": "1"},
{"A": "1", "B": "2"},
{"A": "2", "B": "1"},
{"A": "2", "B": "2"},
]
model = TestModel(
[p_a, p_b],
coverage_goals=[CoverageGoal(["A", "B"])],
existing_coverage=existing,
)
result = solve_model(model, verbose=False)
self.assertTrue(result.is_feasible)
self.assertEqual(len(result.selected_configurations), 0)
def test_include_existing_combines_existing_and_new(self) -> None:
p_a = Parameter("A", [Option("1"), Option("2")])
p_b = Parameter("B", [Option("1"), Option("2")])
existing = [{"A": "1", "B": "1"}, {"A": "2", "B": "2"}]
model = TestModel(
[p_a, p_b],
coverage_goals=[CoverageGoal(["A", "B"])],
existing_coverage=existing,
)
result = CoverageSolver(model, verbose=False).solve(
include_existing=True
)
self.assertTrue(result.is_feasible)
self.assertEqual(len(result.selected_configurations), 4)
# First 2 configs should be the existing ones in order
self.assertEqual(result.selected_configurations[:2], existing)
# Remaining 2 configs cover the gaps
new_interactions = set(
(r["A"], r["B"]) for r in result.selected_configurations[2:]
)
self.assertEqual(new_interactions, {("1", "2"), ("2", "1")})
def test_include_existing_full_coverage(self) -> None:
p_a = Parameter("A", [Option("1"), Option("2")])
p_b = Parameter("B", [Option("1"), Option("2")])
existing = [
{"A": "1", "B": "1"},
{"A": "1", "B": "2"},
{"A": "2", "B": "1"},
{"A": "2", "B": "2"},
]
model = TestModel(
[p_a, p_b],
coverage_goals=[CoverageGoal(["A", "B"])],
existing_coverage=existing,
)
result = solve_model(model, verbose=False, include_existing=True)
self.assertTrue(result.is_feasible)
self.assertEqual(result.selected_configurations, existing)
def test_include_existing_no_goals(self) -> None:
p_a = Parameter("A", [Option("1"), Option("2")])
existing = [{"A": "1"}]
model = TestModel(
[p_a],
existing_coverage=existing,
)
result = CoverageSolver(model, verbose=False).solve(
include_existing=True
)
self.assertTrue(result.is_feasible)
self.assertEqual(result.selected_configurations, existing)
def test_include_existing_partial_raises(self) -> None:
p_a = Parameter("A", [Option("1"), Option("2")])
p_b = Parameter("B", [Option("1"), Option("2")])
model = TestModel(
[p_a, p_b],
coverage_goals=[CoverageGoal(["A"])],
existing_coverage=[ExistingCoverage({"A": "1"}, partial=True)],
)
solver = CoverageSolver(model, verbose=False)
with self.assertRaisesRegex(
ValueError, "Cannot use include_existing=True with partial"
):
solver.solve(include_existing=True)
def test_existing_coverage_robustness_partial(self) -> None:
"""Partial test of existing coverage robustness."""
# Verify an existing config that only covers SOME params MUST be
# wrapped in ExistingCoverage(..., partial=True).
p_a = Parameter("A", [Option("1"), Option("2")])
p_b = Parameter("B", [Option("1"), Option("2")])
p_c = Parameter("C", [Option("1"), Option("2")])
# Attempting to pass a partial dict without "C" should now FAIL
# validation.
with self.assertRaisesRegex(ValueError, "missing parameters"):
TestModel(
[p_a, p_b, p_c],
coverage_goals=[CoverageGoal(["A", "C"])],
existing_coverage=[{"A": "1", "B": "1"}],
)
# But if we mark it partial=True, it should work.
model = TestModel(
[p_a, p_b, p_c],
coverage_goals=[CoverageGoal(["A", "C"])],
existing_coverage=[
ExistingCoverage({"A": "1", "B": "1"}, partial=True)
],
)
result = solve_model(model, verbose=False)
self.assertTrue(result.is_feasible)
# Existing config doesn't satisfy A x C (missing C)
# We need 4 configs for A x C. Existing config covers NONE of them
# because C is missing.
self.assertEqual(len(result.selected_configurations), 4)
def test_existing_coverage_robustness_duplicates(self) -> None:
# Verify duplicate existing configs don't crash or double-count
# inappropriately.
p_a = Parameter("A", [Option("1"), Option("2")])
existing = [{"A": "1"}, {"A": "1"}]
model = TestModel(
[p_a],
coverage_goals=[CoverageGoal(["A"])],
existing_coverage=existing,
)
result = solve_model(model, verbose=False)
# Should just pick A2.
self.assertTrue(result.is_feasible)
self.assertEqual(len(result.selected_configurations), 1)
self.assertEqual(result.selected_configurations[0]["A"], "2")
def test_existing_coverage_robustness_invalid_strict(self) -> None:
# Verify an existing config that violates constraints IS NOW REJECTED.
p_a = Parameter("A", [Option("1"), Option("2")])
p_b = Parameter("B", [Option("1"), Option("2")])
# Constraint: A=1 IMPLIES B!=1 (so A1, B1 is invalid)
ex = Exclusion({"A": "1"}, {"B": {"1"}})
# We provide the invalid config. Should raise ValueError.
invalid_existing = [{"A": "1", "B": "1"}]
with self.assertRaisesRegex(ValueError, "violates model constraints"):
TestModel(
[p_a, p_b],
[ex],
coverage_goals=[CoverageGoal(["A", "B"])],
existing_coverage=invalid_existing,
)
def test_point_constraint_basic(self) -> None:
# Ensure we can force a specific partial config
p_a = Parameter("A", [Option("1"), Option("2")])
p_b = Parameter("B", [Option("1"), Option("2")])
# We want A=1 to be selected, even if not required by coverage goals
# No coverage goals, just a point constraint
pc = PointConstraint({"A": "1"})
model = TestModel([p_a, p_b], point_constraints=[pc])
result = solve_model(model, verbose=False)
self.assertTrue(result.is_feasible)
self.assertTrue(
any(c["A"] == "1" for c in result.selected_configurations)
)
def test_point_constraint_constructive_generation(self) -> None:
# Create a scenario where random sampling is unlikely to find the target
# But we force it via PointConstraint
# 3 params with 10 options each = 1000 combinations
# We only take max_candidates=10 (very small sample)
# It's unlikely to hit A=9, B=9, C=9 by pure chance
opts = [Option(str(i)) for i in range(10)]
p_a = Parameter("A", opts)
p_b = Parameter("B", opts)
p_c = Parameter("C", opts)
pc = PointConstraint({"A": "9", "B": "9", "C": "9"})
model = TestModel([p_a, p_b, p_c], point_constraints=[pc])
# Force small sample size to ensure we rely on constructive gen
result = solve_model(model, verbose=False, max_candidates=5)
self.assertTrue(result.is_feasible)
found = False
for c in result.selected_configurations:
if c["A"] == "9" and c["B"] == "9" and c["C"] == "9":
found = True
break
self.assertTrue(found, "Did not find forced PointConstraint in results")
def test_point_constraint_conflict_infeasible(self) -> None:
# Constraint conflict -> Infeasible
p_a = Parameter("A", [Option("1")])
# Exclusion: A=1 is forbidden
ex = Exclusion({"A": "1"}, {"A": {"1"}})
# PointConstraint: Force A=1
pc = PointConstraint({"A": "1"})
model = TestModel([p_a], constraints=[ex], point_constraints=[pc])
# Should fail to generate candidate, leading to infeasible model
result = solve_model(model, verbose=False)
self.assertFalse(result.is_feasible)
def test_solver_result_stats(self) -> None:
# 2 Parameters, 2 Options each.
p_a = Parameter("A", [Option("1"), Option("2")])
p_b = Parameter("B", [Option("1"), Option("2")])
# Goal: Cover A x B (4 combinations)
# Existing: Covers (A1, B1)
model = TestModel(
[p_a, p_b],
coverage_goals=[CoverageGoal(["A", "B"])],
existing_coverage=[ExistingCoverage({"A": "1", "B": "1"})],
)
result = solve_model(model, verbose=False)
self.assertTrue(result.is_feasible)
# Total requirements: 4 pairs
self.assertEqual(result.total_requirements, 4)
# Covered by existing: 1 pair (A1, B1)
self.assertEqual(result.requirements_covered_by_existing, 1)
# Covered by solver: 3 pairs (A1, B2), (A2, B1), (A2, B2)
# Note: Valid configurations generated by solver will cover these.
self.assertEqual(result.requirements_covered_by_solver, 3)
def test_solver_result_to_json(self) -> None:
result = SolverResult(
status=cp_model.OPTIMAL,
objective_value=10.0,
time_taken=0.123,
selected_configurations=[{"OS": "Linux", "Compiler": "Clang"}],
total_requirements=4,
requirements_covered_by_existing=1,
requirements_covered_by_solver=3,
solver_num_variables=10,
solver_num_constraints=5,
solver_num_literals=8,
)
expected = {
"status": "OPTIMAL",
"is_optimal": True,
"is_feasible": True,
"objective_value": 10.0,
"time_taken": 0.123,
"selected_configurations": [{"OS": "Linux", "Compiler": "Clang"}],
"total_requirements": 4,
"requirements_covered_by_existing": 1,
"requirements_covered_by_solver": 3,
"solver_num_variables": 10,
"solver_num_constraints": 5,
"solver_num_literals": 8,
}
self.assertEqual(result.to_json(), expected)
serialized = json.dumps(result.to_json())
self.assertIsInstance(serialized, str)
self.assertEqual(json.loads(serialized), expected)
class TestCoverageSolverInternals(unittest.TestCase):
"""Tests for the internals of the solver."""
# pylint: disable=protected-access
def test_generate_candidate_pool_enumeration(self) -> None:
# Small domain: 2x2 = 4 candidates. Max = 10. Should enumerate.
p_a = Parameter("A", [Option("1"), Option("2")])
p_b = Parameter("B", [Option("1"), Option("2")])
model = TestModel([p_a, p_b])
solver = CoverageSolver(model, verbose=False, max_candidates=10)
configs, costs = solver._generate_candidate_pool(random.Random(123))
self.assertEqual(len(configs), 4)
# Check costs: all 0? No defaults are 1?
# Wait, Option defaults cost to ?? dataclass defaults?
# Option definition: Option(value, cost=1)
# So cost should be 2 for each.
self.assertEqual(len(costs), 4)
self.assertTrue(
all(c == 2 for c in costs),
"Default cost is 1 per option, so 2 params = 2",
)
# Actually let's restrict cost check or check carefully.
# solver.py:27: Option("Linux", cost=10)
# Let's check Option default.
def test_generate_candidate_pool_sampling(self) -> None:
# Large domain: 10 options x 10 options x 10 options = 1000.
# Max candidates = 50. Should sample.
opts = [Option(str(i)) for i in range(10)]
model = TestModel(
[Parameter("A", opts), Parameter("B", opts), Parameter("C", opts)]
)
solver = CoverageSolver(model, verbose=False, max_candidates=50)
configs, _ = solver._generate_candidate_pool(
rng=random.Random(123),
)
self.assertEqual(len(configs), 50)
def test_generate_candidate_pool_exclusions(self) -> None:
p_a = Parameter("A", [Option("1"), Option("2")])
# Exclusion: A=1 Forbidden.
ex = Exclusion({"A": "1"}, {"A": {"1"}}) # Self-exclusion
model = TestModel([p_a], constraints=[ex])
solver = CoverageSolver(model, verbose=False)
configs, _ = solver._generate_candidate_pool(random.Random(123))
# Should only have A=2
self.assertTrue(all(c["A"] == "2" for c in configs))
def test_enforce_point_constraints_adds_missing(self) -> None:
# A=1..10. Sample size 2. Unlikely to hit A=5.
# PointConstraint A=5.
opts = [Option(str(i)) for i in range(10)]
model = TestModel(
[Parameter("A", opts)],
point_constraints=[PointConstraint({"A": "5"})],
)
solver = CoverageSolver(model, verbose=False, max_candidates=2)
# Manually inject into the pool with something irrelevant
pool = [{"A": "0"}, {"A": "1"}]
costs = [1, 1]
solver._enforce_point_constraints(pool, costs, random.Random(123))
self.assertTrue(any(c["A"] == "5" for c in pool))
self.assertGreater(len(pool), 2)
def test_goal_driven_generation_finds_needle(self) -> None:
"""Test that goal driven generation finds the needle."""
# Scenario: Large domain, small max_candidates.
# We have a goal (A, B).
# We make (A=9, B=9) valid ONLY if C=9.
# Random sampling is unlikely to hit (A=9, B=9, C=9) if domain is large.
# Goal-driven should iterate (A=9, B=9), try to complete it, find C=9
# is needed, and succeed.
# 3 params, 20 options each => 8000 combinations.
opts = [Option(str(i)) for i in range(20)]
p_a = Parameter("A", opts)
p_b = Parameter("B", opts)
p_c = Parameter("C", opts)
# Constraint: IF A=19 AND B=19 THEN C MUST BE 19.
# Actually easier: Make everything invalid EXCEPT A=19,B=19,C=19? No,
# that makes pool empty.
# Let's say we want to cover (A, B).
# Random sampling will fill pool with random stuff.
# We want to ensure (A=19, B=19) is covered.
# We can make (A=19, B=19) only valid with C=19 to make it "hard" to
# construct randomly?
# Or just rely on statistics: 8000 combos. Sample 10. Prob of hitting
# (19, 19, X) is low.
model = TestModel(
[p_a, p_b, p_c], coverage_goals=[CoverageGoal(["A", "B"])]
)
# Use very small max_candidates to disable full enumeration and rely on
# sampling/goal-driven
# Domain 8000 > 10.
solver = CoverageSolver(model, verbose=False, max_candidates=10)
# We expect Goal-Driven to generate candidates for ALL 20x20=400 pairs
# of (A, B).
# One of them will be (A=19, B=19).
# So the pool should contain at least 400 candidates?
# Wait, if we generate 400 goal candidates, we exceed max_candidates=10.
# That's fine, logic says valid_configs.extend(goal_configs) then fill
# remainder.
# So we should end up with >= 400 candidates.
configs, _ = solver._generate_candidate_pool(random.Random(123))
self.assertGreaterEqual(len(configs), 400)
# Verify (A=19, B=19) is present
found = any(c["A"] == "19" and c["B"] == "19" for c in configs)
self.assertTrue(
found,
"Goal-driven generation failed to produce specific target pair",
)
def test_identify_requirements(self) -> None:
p_a = Parameter("A", [Option("1")])
p_b = Parameter("B", [Option("2")])
model = TestModel([p_a, p_b], coverage_goals=[CoverageGoal(["A", "B"])])
solver = CoverageSolver(model, verbose=False)
pool = [{"A": "1", "B": "2"}]
reqs = solver._identify_requirements(pool)
self.assertEqual(len(reqs), 1)
self.assertIn((("A", "B"), ("1", "2")), reqs)
def test_prune_covered_requirements(self) -> None:
p_a = Parameter("A", [Option("1")])
model = TestModel([p_a], existing_coverage=[{"A": "1"}])
solver = CoverageSolver(model, verbose=False)
# Requirement: A=1
reqs = {(("A",), ("1",))}
solver._prune_covered_requirements(reqs)
self.assertEqual(len(reqs), 0)
if __name__ == '__main__':
unittest.main()