Metadata-Version: 2.4
Name: aspcompose
Version: 0.1.0
Summary: Define, group, select, and validate ASP (Answer Set Programming) rule collections.
Project-URL: Homepage, https://github.com/adrienrougny/aspcompose
Project-URL: Issues, https://github.com/adrienrougny/aspcompose/issues
Author-email: Adrien Rougny <adrienrougny@gmail.com>
License: GPL-3.0-or-later
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == 'dev'
Description-Content-Type: text/markdown

# aspcompose

Define, group, select, and validate [ASP](https://potassco.org/) (Answer Set
Programming) rule collections. Solver-agnostic: rule text is opaque to the
library, so any ASP dialect (clingo, DLV, ...) works. Zero runtime
dependencies.

## Install

```bash
pip install -e .[dev]
```

## Quickstart

A single registry exercising all three composition axes — slots, profiles,
and variants — plus a plain `depends_on` chain:

Group identifiers, slot-filler identifiers, and rule identifiers all use
colon-delimited namespaces (`base`, `slot_x:a`, `consumer:variant_1:r_1`)
so every string says where it lives:

```python
from aspcompose import Rule, RuleGroup, RuleRegistry, CollectionPlan

# Plain base group: no slot, no profile, no variants.
base = RuleGroup(
    identifier="base",
    rules=(Rule(identifier="base:r_1", text="p(X) :- q(X)."),),
)

# Two interchangeable fillers of slot_x. Each carries a different profile
# so a bulk `add_profile` pulls in exactly one of them.
slot_x_a = RuleGroup(
    identifier="slot_x:a",
    rules=(Rule(identifier="slot_x:a:r_1", text="q(X) :- a(X)."),),
    depends_on=frozenset({"base"}),
    slot="slot_x",
    profiles=frozenset({"profile_a"}),
)
slot_x_b = RuleGroup(
    identifier="slot_x:b",
    rules=(Rule(identifier="slot_x:b:r_1", text="q(X) :- b(X)."),),
    depends_on=frozenset({"base"}),
    slot="slot_x",
    profiles=frozenset({"profile_b"}),
)

# Polymorphic consumer: depends on slot_x (any filler will do), carries
# both profiles so it's included under either, and declares two variants
# that both derive from the shared predicate `r(X)` — so they work the
# same regardless of which slot_x filler is active.
consumer = RuleGroup(
    identifier="consumer",
    rules=(Rule(identifier="consumer:r_1", text="r(X) :- p(X)."),),
    depends_on=frozenset({"base", "slot_x"}),
    profiles=frozenset({"profile_a", "profile_b"}),
    variants={
        "variant_1": (
            Rule(identifier="consumer:variant_1:r_1", text="u(X) :- r(X)."),
        ),
        "variant_2": (
            Rule(identifier="consumer:variant_2:r_1", text="v(X) :- r(X)."),
        ),
    },
)

# Opt-in extension (plain group, no slot or profile). Two rules show the
# `r_1`/`r_2` numbering inside a single group's namespace.
extension = RuleGroup(
    identifier="extension",
    rules=(
        Rule(identifier="extension:r_1", text="t(X) :- r(X)."),
        Rule(identifier="extension:r_2", text="s(X) :- t(X)."),
    ),
    depends_on=frozenset({"consumer"}),
)

registry = RuleRegistry()
registry.register([base, slot_x_a, slot_x_b, consumer, extension])
assert registry.validate() == []

# One plan, two outputs — variants are decided at resolve() time, so the
# same selection produces different rule sets without re-planning.
plan_a = CollectionPlan(registry)
plan_a.add_profile("profile_a")      # includes: slot_x:a, consumer
plan_a.auto_include_deps()           # pulls in concrete deps: base

for rule in plan_a.resolve(variant="variant_1"):
    print(rule.text)
# p(X) :- q(X).          (base)
# q(X) :- a(X).          (slot_x:a, filling slot_x)
# r(X) :- p(X).          (consumer, shared)
# u(X) :- r(X).          (consumer, variant_1 payload)

for rule in plan_a.resolve(variant="variant_2"):
    print(rule.text)
# p(X) :- q(X).
# q(X) :- a(X).
# r(X) :- p(X).
# v(X) :- r(X).          (consumer, variant_2 payload — same plan, new flavor)

# A different profile picks a different slot_x filler, and the extension
# is pulled in explicitly. The same variant_1 still works — variants
# don't care which filler is active, because they only depend on `r(X)`.
plan_b = CollectionPlan(registry)
plan_b.add_profile("profile_b")      # includes: slot_x:b, consumer
plan_b.add_group("extension")        # opt-in, no profile carries it
plan_b.auto_include_deps()

for rule in plan_b.resolve(variant="variant_1"):
    print(rule.text)
# p(X) :- q(X).
# q(X) :- b(X).          (slot_x:b, filling slot_x)
# r(X) :- p(X).
# u(X) :- r(X).          (consumer, variant_1 payload — unchanged)
# t(X) :- r(X).          (extension, rule r_1)
# s(X) :- t(X).          (extension, rule r_2)
```

What each axis does in this example:

- **`slot_x`** picks *which group* defines `q(X)` — `slot_x:a` or
  `slot_x:b` — while `consumer` depends on `slot_x` polymorphically,
  without naming either filler.
- **`profile_a`** / **`profile_b`** are bulk toggles: one call to
  `add_profile` pulls in a coherent set of groups (here, a `slot_x`
  filler and the `consumer`). The `extension` group carries no profile
  and is added with `add_group` when wanted.
- **`variant_1`** / **`variant_2`** switch `consumer`'s emitted rule
  payload at `resolve()` time. The variants derive from the shared
  `r(X)`, so the choice is independent of both the profile and the slot
  filler — `plan_a` renders cleanly under either variant, and
  `variant_1` works the same in `plan_b`.
- `auto_include_deps` only walks concrete `depends_on` edges; slot deps
  are left to the user (or to a profile) to resolve.

## Concepts

- **`Rule`** — a single ASP rule with an identifier, source text, and optional
  docs.
- **`RuleGroup`** — rules that are always added/removed together. Declares
  `depends_on` (group identifiers or slot names), may fill a `slot`, may
  carry any number of `profiles` (tags), and may define per-flavor
  `variants`. Frozen.
- **Slot** — an abstract role that multiple groups can fill (e.g.
  `"input_format"`, filled by `format_sbml` *or* `format_kgml`). A plan may
  include at most one filler per slot, and `depends_on` may name a slot
  instead of a concrete group — that's a polymorphic dependency on "some
  filler of this role".
- **Profile** — a free-form label carried by any number of groups (e.g.
  `"experimental"`, `"sbml"`). `plan.add_profile(name)` includes every
  group tagged with it in a single call.
- **Variant** — a format/flavor key declared inside a group:
  `variants={"key": (Rule, ...)}`. `plan.resolve(variant="key")` emits
  `rules` plus the matching payload, so one plan can produce N programs
  (one per variant key).
- **`RuleRegistry`** — stores every known group. `validate()` catches
  unknown deps and cycles.
- **`CollectionPlan`** — user-curated `included` / `excluded` selection.
  `validate()` returns structured issues; `resolve()` returns a flat,
  ordered rule list or raises `PlanInvalidError`.

### Slots vs profiles vs variants

The three are orthogonal: they act on different axes of the composition
problem, and a single group can use all three at once without interference.

|               | **Slot**                                     | **Profile**                          | **Variant**                               |
| ------------- | -------------------------------------------- | ------------------------------------ | ----------------------------------------- |
| Answers       | "which group fills this role?"               | "which groups go together?"          | "which rule flavor to emit?"              |
| Cardinality   | at most one filler per slot, per plan        | any number of tagged groups          | one variant key per `resolve()` call      |
| Decided at    | plan-composition time                        | plan-composition time                | `resolve()` time — same plan, N outputs   |
| In `depends_on` | yes — polymorphic dependency               | no                                   | no                                        |
| Conflict      | `slot_conflict` if two fillers are included | none                                 | `missing_variant` if the key isn't found  |

Use cases:

- **Slot** — interchangeable implementations of the same interface. An
  `input_format` slot filled by `format_sbml` *or* `format_kgml`; a
  `solver_hints` slot filled by `clingo_hints` *or* `dlv_hints`. Downstream
  groups write `depends_on={"input_format"}` and don't care which filler
  the user picks.
- **Profile** — bulk toggles for cross-cutting themes. Tag a dozen groups
  scattered across features with `"experimental"`; flip them all on at
  once with `plan.add_profile("experimental")`. Profiles never exclude
  anything and never conflict — they're a user-ergonomic shortcut, not a
  composition constraint.
- **Variant** — one group, multiple output flavors. A `pathways` group has
  shared logic in `rules` plus a few format-specific tail rules in
  `variants={"sbml": ..., "kgml": ...}`. The same plan yields two
  programs via `resolve(variant="sbml")` and `resolve(variant="kgml")` —
  no re-selection, no duplicate groups, no N×M explosion.

A single group can fill a slot, carry profiles, and define variants all at
the same time.

## Validation issues

| `kind`                 | Meaning                                                      |
| ---------------------- | ------------------------------------------------------------ |
| `missing_dependency`   | Included group requires a group that is not included        |
| `excluded_dependency`  | Included group requires a group that is explicitly excluded |
| `unfilled_slot`        | Included group needs a slot but no group filling it is in   |
| `slot_conflict`        | Two included groups fill the same slot                      |
| `unknown_dependency`   | A `depends_on` target is not registered                     |
| `missing_variant`      | Included group has variants but not for the requested key  |
| `cycle`                | Cyclic `depends_on` graph (registry-level)                  |

## Testing

```bash
pytest
```
