Skip to content
cpbenchy 0.1.0.dev0 is in alpha: until version 1.0, commands, options, the Python API and the result format may still change. Pin the version you use.

cpbenchy.formats

Turns solutions into the text formats competitions and file formats use, and back.

Updated View as Markdown
ModuleBuilt incomes with cpbenchy
from cpbenchy import formats
Version
cpbenchy 0.1.0.dev0
Last updated
6 Oct 2026 · 1 commit
Authors
ThomSerg
Tags
solutionsformatsbuilding block

What it does

The pieces the competition observers are made of, to write solutions as text in your own observers, and to read them back. Everything in it can be used inside the measured run.

Use it

import cpbenchy
from cpbenchy import formats

class SolutionXML(cpbenchy.Observer):
    def on_finish(self, ctx):
        if ctx.status in ("optimal", "feasible"):
            xml = formats.xcsp3_instantiation(formats.instance_variables(ctx.model), ctx.objective)
            ctx.artifact("solution.xml").write_text(xml)

Functions

Function Returns
instance_variables(model) the model’s variables in order, without CPMpy’s auxiliary ones
solution_dict(variables) {name: value}, with plain bools and ints
assignment(variables, prefix="x") {number: bool} for variables named x1, x2, …
literals(assignment, n=None, prefix="") ["1", "-2", ...], or ["x1", "-x2", ...] with prefix="x"
bits(assignment, n=None) "0101..."
dimacs_values(assignment, n=None, width=20) SAT competition v lines, ending in 0
xcsp3_instantiation(variables, cost=None) an XCSP3 <instantiation> element, as text
status_line(status, has_objective) "s OPTIMUM FOUND", "s SATISFIABLE", "s UNSATISFIABLE" or "s UNKNOWN"
header_size(path, open=open) the number of variables a CNF, WCNF or OPB header declares, or None
load_cnf(path, open=open) a CNF file as a cpmpy.Model with variables x1, x2, …
read_competition_output(text) (answer of the s line, last o value, v lines)
read_xcsp3_instantiation(text) ({name: value}, cost) from an <instantiation>
read_literals(text, prefix="x"), read_bits(text, prefix="x") {name: bool} from literals or 0s and 1s
number(x) an objective value as text, without .0 for integral values

n is the number of variables to write, usually from header_size. Variables up to n without a value are written as false. To read compressed files, pass open=cpbenchy.Loader().opener(path).

Implementation

The file src/cpbenchy/formats.py, 204 lines, as of this version of the docs.

src/cpbenchy/formats.pypython
"""Solutions in the formats of instance files and solver competitions, for your own observers.

The observers in `cpbenchy.observers` use these to write competition output; use them directly for
anything else that needs a solution as text:

    from cpbenchy import formats

    class MyOutput(cpbenchy.Observer):
        def on_finish(self, ctx):
            if ctx.status in ("optimal", "feasible"):
                variables = formats.instance_variables(ctx.model)
                ctx.artifact("sol.xml").write_text(formats.xcsp3_instantiation(variables, ctx.objective))

Most functions take an assignment of numbered variables (`{1: True, 2: False, ...}`, from variables
named `x1`, `x2`, ... as the DIMACS, WCNF and OPB loaders name them) and the number of variables in the
instance (`header_size`), so variables that occur in no constraint still get a value.

Imported inside the measured worker process: Python 3.10 compatible, CPMpy imported only when needed.
"""

import builtins
import re
import xml.etree.ElementTree as ET
from collections.abc import Callable, Iterable
from typing import Any

AUX_PREFIXES = ("IV", "BV", "B#")
"""Names of CPMpy's auxiliary variables, which loaders and transformations introduce."""

STATUS_LINES = {"unsat": "s UNSATISFIABLE", "unknown": "s UNKNOWN"}

_HEADERS = (
    re.compile(r"^p\s+w?cnf\s+(\d+)"),  # DIMACS CNF and WCNF before 2022: p cnf <vars> <clauses>
    re.compile(r"^\*\s*#variable=\s*(\d+)"),  # OPB: * #variable= <vars> #constraint= <constraints>
)


def instance_variables(model: Any) -> list:
    """The decision variables of a `cpmpy.Model`, in order of appearance, without CPMpy's auxiliary ones."""
    from cpmpy.transformations.get_variables import get_variables_model

    return [v for v in get_variables_model(model) if not v.name.startswith(AUX_PREFIXES)]


def value(var: Any) -> Any:
    """A variable's value as a plain `bool` or `int` (None if it has none), e.g. for JSON."""
    val = var.value()
    if val is None:
        return None
    return bool(val) if var.is_bool() else int(val)


def solution_dict(variables: Iterable[Any]) -> dict[str, Any]:
    """{name: value} of these variables."""
    return {v.name: value(v) for v in variables}


def number(x: float | None) -> str:
    """An objective value as text: integral values without a decimal point."""
    if x is None:
        return ""
    return str(int(x)) if float(x).is_integer() else repr(float(x))


def status_line(status: str | None, has_objective: bool | None) -> str:
    """The `s` line of the XCSP3, PB, MaxSAT and SAT competitions for a run's status."""
    if status == "optimal" and has_objective:
        return "s OPTIMUM FOUND"
    if status in ("optimal", "feasible"):
        return "s SATISFIABLE"
    return STATUS_LINES.get(status or "unknown", "s UNKNOWN")


def assignment(variables: Iterable[Any], prefix: str = "x") -> dict[int, bool]:
    """{number: value} of the Boolean variables named `<prefix><number>`; the others are left out."""
    n = len(prefix)
    return {
        int(v.name[n:]): bool(v.value())
        for v in variables
        if v.name.startswith(prefix) and v.name[n:].isdigit() and v.value() is not None
    }


def literals(assignment: dict[int, bool], n: int | None = None, prefix: str = "") -> list[str]:
    """Each variable 1..n as a literal: `x1`/`-x1` with prefix "x" (OPB), `1`/`-1` without (DIMACS).
    Variables without a value are false; n defaults to the highest number in the assignment."""
    n = n if n is not None else max(assignment, default=0)
    return [f"{prefix}{i}" if assignment.get(i) else f"-{prefix}{i}" for i in range(1, n + 1)]


def bits(assignment: dict[int, bool], n: int | None = None) -> str:
    """Variables 1..n as a string of 0s and 1s, as the MaxSAT Evaluation's `v` line."""
    n = n if n is not None else max(assignment, default=0)
    return "".join("1" if assignment.get(i) else "0" for i in range(1, n + 1))


def dimacs_values(assignment: dict[int, bool], n: int | None = None, width: int = 20) -> list[str]:
    """The `v` lines of the SAT competition (without the `v `), `width` literals per line, ending in 0."""
    lits = [*literals(assignment, n), "0"]
    return [" ".join(lits[i : i + width]) for i in range(0, len(lits), width)]


def xcsp3_instantiation(variables: Iterable[Any], cost: float | None = None) -> str:
    """The solution as an XCSP3 `<instantiation>` element: of type "optimum" with a `cost` if given, else
    "solution". Variables without a value get `*`."""
    variables = list(variables)
    if cost is not None:
        root = ET.Element("instantiation", type="optimum", cost=number(cost))
    else:
        root = ET.Element("instantiation", type="solution")
    ET.SubElement(root, "list").text = " ".join(v.name for v in variables)
    ET.SubElement(root, "values").text = " ".join(
        str(int(v.value())) if v.value() is not None else "*" for v in variables
    )
    ET.indent(root)
    return ET.tostring(root, encoding="unicode")


def header_size(path: str, open: Callable = builtins.open) -> int | None:
    """The number of variables a DIMACS CNF, WCNF or OPB file declares in its header, if it has one."""
    with open(path, "rt") as f:
        for line in f:
            for header in _HEADERS:
                match = header.match(line)
                if match:
                    return int(match.group(1))
            if line.strip() and not line.startswith(("c", "*")):  # the header comes before the content
                return None
    return None


def load_cnf(path: str, open: Callable = builtins.open) -> Any:
    """A DIMACS CNF file as a `cpmpy.Model` with variables named `x1`, `x2`, ..., so solutions can be
    written in DIMACS (CPMpy's own loader gives them generated names)."""
    import cpmpy as cp

    variables: dict[int, Any] = {}

    def literal(lit: int) -> Any:
        if abs(lit) not in variables:
            variables[abs(lit)] = cp.boolvar(name=f"x{abs(lit)}")
        return variables[abs(lit)] if lit > 0 else ~variables[abs(lit)]

    clauses, clause = [], []
    with open(path, "rt") as f:
        for line in f:
            if not line.strip() or line.startswith(("c", "p", "%")):
                continue
            for token in line.split():
                lit = int(token)
                if lit == 0:
                    clauses.append(cp.any(clause) if clause else cp.BoolVal(False))
                    clause = []
                else:
                    clause.append(literal(lit))
    if clause:  # a last clause without its 0
        clauses.append(cp.any(clause))
    return cp.Model(clauses)


# --- reading solutions back, e.g. to check them after the run (`cpbenchy.check`) ---


def read_competition_output(text: str) -> tuple[str | None, float | None, list[str]]:
    """Competition output -> (the `s` line's answer, the last `o` value, the `v` lines without `v `)."""
    status, objective, values = None, None, []
    for line in text.splitlines():
        if line.startswith("s "):
            status = line[2:].strip()
        elif line.startswith("o "):
            objective = float(line[2:].strip())
        elif line.startswith("v ") or line == "v":
            values.append(line[2:])
    return status, objective, values


def read_xcsp3_instantiation(text: str) -> tuple[dict[str, int], float | None]:
    """An XCSP3 `<instantiation>` -> ({name: value}, its cost if it has one). Variables with value `*` are
    left out. Lists must name each variable (no `x[]` shorthands), as `xcsp3_instantiation` writes them."""
    root = ET.fromstring(text)
    names = (root.findtext("list") or "").split()
    values = (root.findtext("values") or "").split()
    if len(names) != len(values):
        raise ValueError(f"the instantiation has {len(names)} variables but {len(values)} values")
    cost = root.get("cost")
    solution = {name: int(val) for name, val in zip(names, values, strict=True) if val != "*"}
    return solution, float(cost) if cost is not None else None


def read_literals(text: str, prefix: str = "x") -> dict[str, bool]:
    """Literals (`x1 -x2` or DIMACS `1 -2 0`) -> {name: value}; numbers become `<prefix><number>`."""
    solution = {}
    for token in text.split():
        negated = token.startswith("-")
        name = token.lstrip("-")
        if name == "0":
            continue
        solution[prefix + name if name.isdigit() else name] = not negated
    return solution


def read_bits(text: str, prefix: str = "x") -> dict[str, bool]:
    """A string of 0s and 1s (MaxSAT) -> {"x1": ..., "x2": ...}."""
    return {f"{prefix}{i}": bit == "1" for i, bit in enumerate(text.strip(), start=1)}
Navigation

Type to search…

↑↓ navigate↵ selectEsc close