Show / Hide Table of Contents

Custom Rules

A rule's main job is to provide the possible values at any coordinate based on its criteria. It does this through the IRule.GetPossibleValues method. The rule does not, however, enforce its criteria. That's a job for the IRuleKeeper.

The rule keeper will call Update and Revert to inform the rule about changes to the puzzle. When a value is going to be set, the rule keeper calls Update so that the rule can track any necessary changes to the possible values across the puzzle according to its own criteria. It also identifies any coordinates that have been affected by the change.

The rule keeper then verifies that the puzzle is still solvable after these changes. If the puzzle is no longer solvable, or if the rule keeper is undoing a previous move, it will call Revert. This tells the rule that a coordinate was previously set to a given value, but is now being unset. It essentially does the opposite change to Update, and optionally identifies affected coordinates.

Let's look at an example. Say we want to solve a puzzle that also enforces that the diagonals contain all unique values. In this case, we can still use the standard PuzzleWithPossibleValues to store the data, but we need to add a custom rule. In this example, we'll go through the steps for implementing and using the DiagonalUniquenessRule.

Creating a rule

The new rule needs to extend IRule, and we'll need to have some way of tracking the set of values that are available in each diagonal. For this, we'll use the BitVector struct, which provides an efficient set-like struct using the 32 bits of a uint.

TryInit

When starting to solve a puzzle, the rule keeper will call IRule.TryInit. This is where we setup our class and perform initial checks against this rule.

Note: Rules are stateful, so a given rule should never be used to solve multiple puzzles at once, or acted on from multiple threads.

public class DiagonalUniquenessRule : IRule
{
    // The puzzle being solved
    private IReadOnlyPuzzle? _puzzle;
    // All the possible values for each diagonal
    private BitVector _allPossibleValues;
    // The current unset values on each diagonal
    private BitVector _unsetBackwardDiag;
    private BitVector _unsetForwardDiag;

    public bool TryInit(IReadOnlyPuzzle puzzle, BitVector uniquePossibleValues)
    {
        _puzzle = puzzle;
        _unsetBackwardDiag = _unsetForwardDiag = _allPossibleValues = uniquePossibleValues;
        // Iterate through the backward diagonal (like a backslash '\')
        for (int row = 0, col = 0; row < puzzle.Size; row++, col++)
        {
            var val = puzzle[row, col];
            if (val.HasValue)
            {
                if (!_unsetBackwardDiag.IsBitSet(val.Value))
                {
                    // Puzzle has a duplicate value on this diagonal, so it already violates
                    // the rule.
                    return false;
                }
                _unsetBackwardDiag.UnsetBit(val.Value);
            }
        }
        
        // TODO: Validate the forward diagonal, and update _unsetForwardDiag accordingly.
    }
}

GetPossibleValues

Now we need to implement the IRule.GetPossibleValues operation, which provides the possible values for any square according to this rule. You might be wondering, what should we return if the given coordinate is not on one of the diagonals? In that case, we should return all possible values! We definitely don't want to return no possible values, because that would make any puzzle with unset squares off the diagonal impossible to solve.

public BitVector GetPossibleValues(in Coordinate c)
{
    if (_IsOnBackwardDiag(in c))
    {
        return _unsetBackwardDiag;
    } else if (_IsOnForwardDiag(in c))
    {
        return _unsetForwardDiag;
    } else
    {
        return _allUnset;
    }
}

private static bool _IsOnBackwardDiag(in Coordinate c)
{
    return c.Row == c.Column;
}

private bool _IsOnForwardDiag(in Coordinate c)
{
    return c.Column == _puzzle.Size - c.Row - 1;
}

Update

Great, now we need to provide a way for the rule keeper to update this rule. When the rule keeper wants to set a square's value, it will call IRule.Update with the location and new value. It will also include a CoordinateTracker, with which this rule needs to track any coordinates whose possible values have changed.

public void Update(in Coordinate c, int val, CoordinateTracker coordTracker)
{
    if (_IsOnBackwardDiag(in c))
    {
        // Remove this value from the list of possible values.
        _unsetBackwardDiag.UnsetBit(val);
        _AddUnsetFromBackwardDiag(in c, coordTracker);
    }
    // TODO: Handle an update that's on the forward diagonal.
}

private void _AddUnsetFromBackwardDiag(in Coordinate c, CoordinateTracker coordTracker)
{
    // Iterate along the backward diagonal, tracking the coordinates of any unset squares.
    for (int row = 0, col = 0; row < _puzzle.Size; row++, col++)
    {
        // Make sure to skip the square that is currently being updated! Its value is still
        // unset.
        if ((row == c.Row && col == c.Column) || _puzzle[row, col].HasValue)
        {
            continue;
        }
        coordTracker.AddOrTrackIfUntracked(new Coordinate(row, col));
    }
}

Revert

Ok, now what if that update needs to be reverted, for example if the solver made a wrong guess? Let's implement the IRule.Revert methods. These should both perform roughly the same changes: reverting the changes made during the Update method. However, in one we can skip tracking the affected square's in the CoordinateTracker.

public void Revert(in Coordinate c, int val)
{
    if (_IsOnBackwardDiag(in c))
    {
        _unsetBackwardDiag.SetBit(val);
    }
    // TODO: Handle the case when the square is on the forward diagonal
}

public void Revert(in Coordinate c, int val, CoordinateTracker coordTracker)
{
    if (_IsOnBackwardDiag(in c))
    {
        _unsetBackwardDiag.SetBit(val);
        _AddUnsetFromBackwardDiag(in c, coordTracker);
    }
    // TODO: Handle the case when the square is on the forward diagonal
}

CopyWithNewReference

Lastly, we must implement the IRule.CopyWithNewReference method to provide a deep copy for some solver and generator methods. The given puzzle should be a copy of the current puzzle, if any, else null.

public IRule CopyWithNewReference(IReadOnlyPuzzle? puzzle)
{
    return new DiagonalUniquenessRule(this, puzzle);
}

private DiagonalUniquenessRule(DiagonalUniquenessRule existing, IReadOnlyPuzzle? puzzle)
{
    _puzzle = puzzle;
    // BitVectors are structs, so they are copied on assignment.
    _unsetBackwardDiag = existing._unsetBackwardDiag;
    _unsetForwardDiag = existing._unsetForwardDiag;
    _allUnset = existing._allUnset;
}

Using the new rule

Now let's assume we have a puzzle already that we want to solve with this rule. We can solve it as follows:

var solver = new PuzzleSolver(
    new DynamicRuleKeeper(
        new IRule[]
        {
            new StandardRules(puzzle, possibleValues.AllPossible),
            new DiagonalUniquenessRule(puzzle, possibleValues.AllPossible),
		}));
var solved = solver.Solve(puzzle);

Similarly, we can generate new puzzles that follow this rule as below:

var generator = new PuzzleGenerator<Puzzle>(
    size => new Puzzle(size),
    new Solver(
		new DynamicRuleKeeper(
			new IRule[]
			{
				new StandardRules(puzzle, possibleValues.AllPossible),
				new DiagonalUniquenessRule(puzzle, possibleValues.AllPossible),
			})));
var puzzle = generator.Generate(size, numSetSquares, timeout);

Remember to include heuristics for best performance!

  • Improve this Doc
In This Article
Back to top Generated by DocFX