solverforge-cli 3.3.3

CLI for scaffolding and managing SolverForge constraint solver projects
# Constraint patterns

Every `solverforge generate constraint <id> --<pattern> [--hard|--soft]` writes
`src/constraints/<id>.rs` and wires it into `src/constraints/mod.rs` (managed
blocks) and, for web shells, `static/sf-config.json`. Do not wire constraints by
hand.

The generated file is a **stub**: a real `ConstraintFactory` call streaming from
the generated collection accessors, plus placeholder functions that `panic!`.
Its imports, entity type, scalar variable, and fact type are selected from the
**first** entity, first scalar variable, and first fact in the model. In any
multi-collection model, replace those generated choices with the collections the
constraint actually reads. Then replace the placeholders.

## One mandatory edit

1. **Real logic.** Replace every `panic!(...)` placeholder body.

The stream sources need no swap: every skeleton already streams from the
`#[planning_solution]`-generated accessors.

### Why the source matters

`#[planning_solution]` generates `Plan::tasks()`, `Plan::resources()`, etc. — one
public accessor per collection. Each carries a `ChangeSource`
(`Descriptor(i)` for planning entities, `Static` for facts/list elements). A
hand-written extractor such as `fn entity_items(solution: &Plan) -> &[Task]` has
`ChangeSource::Unknown`.

- No pattern works over `Unknown` at solve time: `initialize` evaluates, but
  the first solver-applied move panics on `on_insert`/`on_retract` — unary and
  reward included — with `source Unknown cannot localize entity indexes`.

So use `Plan::<collection>()` everywhere and delete the extractor functions:

```rust
.for_each(Plan::tasks())          // planning entity collection `tasks: Vec<Task>`
.for_each(Plan::resources())      // fact collection `resources: Vec<Resource>`
```

The generated trait `PlanConstraintStreams` exists but is not re-exported by the
scaffold's `src/domain/mod.rs`, so prefer the associated accessor
(`Plan::tasks()`) over `.tasks()` and skip the trait import.

### The `.named(...)` must equal the module id

The model contract requires `constraint module '<id>'` to declare
`.named("<id>")`. If you rename a constraint, rename both the file and the
`.named` string, or `solverforge check` fails. Keep the CLI-assigned name.

## Hard constraints are penalties, not filters

A hard constraint **penalizes** an invalid candidate; it does not remove that
candidate from search. Construction can start from a hard-violating plan, and
local search is allowed to return one if it cannot improve the hard score. A
converged solve with a nonzero hard score means invalid candidates are
reachable — it does not mean the constraint is wrong.

Do not "fix" a returned plan with a post-solve sanitizer or by re-checking rules
in app code. Use one of:

1. **Keep invalid values out of the model's value range.** Restrict what the
   variable can take so a violating assignment is never generated. On a
   scaffolded project, use the scalar/list candidate metadata
   (`--candidate-values`, list metadata) or `generate scalar-group`; on an
   existing model, use a `value_range_provider`/candidate provider. A provider
   may compute availability, but the rule itself must still be stated as a
   constraint over domain objects (for example, availability on a fact read by a
   join), not as a precomputed penalty matrix.
2. **Gate grouped/repair moves on hard improvement.** For scalar groups and
   conflict repair, `require_hard_improvement = true` makes each emitted
   compound move carry a hard-improvement gate. The CLI writes this into the
   `grouped_scalar_move_selector` and compound conflict-repair phases it
   generates; set it manually for hand-written `solver.toml`.
3. **Model "must be assigned" explicitly.** Use `.unassigned()` (hard) when
   assignment is mandatory, or a separate medium constraint when it is a
   preference. Do not duplicate the assignment penalty inside overlap rules.

Never precompute the rule's **verdict per planning entity** — a
`Lesson.feasible: Vec<bool>` or per-slot `allowed: Vec<bool>` scored by a
constraint. It hides the rule from score analysis and is the classic way an
invalid plan is returned with a clean-looking constraint module.

Factual input data owned by a fact is different and is expected: a teacher's
availability calendar, a slot's start/end, or a prepared travel-time matrix is
read by the constraint and scored there (see the availability recipe below and
`routing-and-maps.md`). The test is whether the data is an observation about the
world or a copy of the rule's decision.

## Weight vocabulary

```rust
use solverforge::prelude::*;

<HardSoftScore as Score>::one_hard()   // one hard violation
<HardSoftScore as Score>::one_soft()   // one soft violation
<HardSoftScore as Score>::zero()       // no violation
```

- Hard streams take a weight function wrapped by `hard_weight(fn)`, where `fn`
  returns a `Score`.
- Soft `penalize`/`reward` take the weight function directly.
- The static constants `HardSoftScore::ONE_HARD` / `ONE_SOFT` work too (used by
  the `unassigned()` example below).

## Verified patterns

`unary`, `reward`, `pair`, and `join` below were verified end to end (compiled
and solved without panic, producing a score). The grouped patterns use the same
wiring rule; treat their collectors as stubs and verify each with a real solve.

### unary — penalize or reward matching entities

When: one entity's own state is illegal or desirable (unassigned, over a
threshold, on a bad day). No join, no grouping.

```rust
use crate::domain::{Plan, Task};
use solverforge::prelude::*;
use solverforge::IncrementalConstraint;

/// HARD: penalize every task that carries a negative priority.
pub fn constraint() -> impl IncrementalConstraint<Plan, HardSoftScore> {
    ConstraintFactory::<Plan, HardSoftScore>::new()
        .for_each(Plan::tasks())
        .penalize(hard_weight(unary_weight))
        .named("required_positive")
}

fn unary_weight(task: &Task) -> HardSoftScore {
    if task.priority < 0 { <HardSoftScore as Score>::one_hard() }
    else { <HardSoftScore as Score>::zero() }
}
```

For soft: `.reward(unary_weight)` (or `.penalize(unary_weight)`) with a
`one_soft()` body and no `hard_weight`.

### unassigned — hard "every entity must be assigned"

When: assignment is mandatory. The stream is empty when all are assigned.

```rust
ConstraintFactory::<Plan, HardSoftScore>::new()
    .for_each(Plan::tasks())
    .unassigned()
    .penalize(HardSoftScore::ONE_HARD)
    .named("required_assignment")
```

The variable must allow unassigned values (`--allows-unassigned`).

### pair — penalize conflicting pairs of the same entity type

When: two entities with the same assigned value conflict (double-booking,
overlap, same resource twice). Uses a self-join keyed by the planning variable.

```rust
use crate::domain::{Plan, Task};
use solverforge::prelude::*;
use solverforge::stream::joiner::equal;
use solverforge::IncrementalConstraint;

/// HARD: two distinct high-priority tasks must not share a resource.
pub fn constraint() -> impl IncrementalConstraint<Plan, HardSoftScore> {
    ConstraintFactory::<Plan, HardSoftScore>::new()
        .for_each(Plan::tasks())
        .join(equal(|task: &Task| task.resource_idx))
        .penalize(hard_weight(pair_weight))
        .named("no_double_book")
}

fn pair_weight(left: &Task, right: &Task) -> HardSoftScore {
    if left.priority >= 3 && right.priority >= 3 {
        <HardSoftScore as Score>::one_hard()
    } else {
        <HardSoftScore as Score>::zero()
    }
}
```

A self-join visits each unordered pair **exactly once** and never pairs an
entity with itself, so one match is one violation. Do not add a self-pair guard
and do not halve the weight.

### join — penalize an entity against a joined fact

When: the entity's assigned value must satisfy a property of the fact it points
at (capacity, skill, distance, compatibility). Join key on the entity side is
the scalar variable (an index); the fact side needs a numeric field to join on.

```rust
use crate::domain::{Plan, Resource, Task};
use solverforge::prelude::*;
use solverforge::stream::joiner::equal_bi;
use solverforge::IncrementalConstraint;

/// HARD: a task's demand must not exceed its assigned resource capacity.
pub fn constraint() -> impl IncrementalConstraint<Plan, HardSoftScore> {
    ConstraintFactory::<Plan, HardSoftScore>::new()
        .for_each(Plan::tasks())
        .join((
            Plan::resources(),
            equal_bi(entity_join_key, fact_join_key),
        ))
        .penalize(hard_weight(join_weight))
        .named("capacity")
}

fn entity_join_key(task: &Task) -> Option<usize> { task.resource_idx }
fn fact_join_key(resource: &Resource) -> Option<usize> { resource.slot.checked_sub(1) }

fn join_weight(task: &Task, resource: &Resource) -> HardSoftScore {
    if task.demand > resource.capacity {
        <HardSoftScore as Score>::one_hard()
    } else {
        <HardSoftScore as Score>::zero()
    }
}
```

Facts have no implicit index. When you need "the fact at index i", add an
explicit `slot:usize` (or similar) field to the fact so both sides can produce a
comparable key. For enum/string compatibility, key both sides to the same
`String`/id.

### reward — soft incentive for matching entities

When: a desirable property should be maximized. `--reward` implies soft.

```rust
ConstraintFactory::<Plan, HardSoftScore>::new()
    .for_each(Plan::tasks())
    .reward(reward_weight)
    .named("prefer_high_priority")

fn reward_weight(task: &Task) -> HardSoftScore {
    if task.priority >= 0 { <HardSoftScore as Score>::one_soft() }
    else { <HardSoftScore as Score>::zero() }
}
```

### balance — spread a metric across groups

When: fairness / load balancing (workload per employee, load per machine). The
collector is macro-generated; replace the placeholder key/metric functions with
real accessors.

```rust
use solverforge::stream::collector::LoadBalance;

ConstraintFactory::<Plan, HardSoftScore>::new()
    .for_each(Plan::tasks())
    .group_by(scope_key, load_balance(group_key, metric))
    .penalize(balance_weight)
    .named("balanced_load")
```

`scope_key: fn(&Task) -> usize` groups the balance problem, `group_key` is the
bucket (usually the assigned value), `metric: fn(&Task) -> i64` is the weight
carried. The skeleton's `load_balance<Option<usize>>` type must match your key
type. `--balance` implies soft, so `balance_weight` must return a soft score
(`one_soft()` or a proportional soft weight); do not wrap it with `hard_weight`.

### runs / presence / collect-vec / group-complement / projected-group

These are grouped/collector skeletons over an index-based planning variable:

- `--runs`: consecutive runs of a value (`consecutive_runs`).
- `--presence`: presence of indexed values (`indexed_presence`).
- `--collect-vec`: collect values into a vector (`collect_vec`).
- `--group-complement`: group entities then complement against a fact collection
  (`count()` + `.complement(...)`).
- `--projected-group`: join to facts, project a row, then group (`project` +
  `group_by`).

Each generated file compiles but every collector closure is a `panic!`
placeholder. Implement each closure with real indexing logic and use
`Plan::<collection>()` for all sources. Verify with a real solve: collector
semantics (ordering, index ranges, empty groups) are easy to get subtly wrong.

## Time, windows, and overlaps

Derived from
[`uc-lessons`](https://github.com/SolverForge/solverforge-usecases/tree/main/uc-lessons),
the canonical reference for slot/window scheduling. Read that app before
inventing time arithmetic; the worked pieces are
[`src/domain/timeslot.rs`](https://github.com/SolverForge/solverforge-usecases/blob/main/uc-lessons/src/domain/timeslot.rs),
[`src/constraints/teacher_availability.rs`](https://github.com/SolverForge/solverforge-usecases/blob/main/uc-lessons/src/constraints/teacher_availability.rs),
[`src/constraints/no_teacher_conflict.rs`](https://github.com/SolverForge/solverforge-usecases/blob/main/uc-lessons/src/constraints/no_teacher_conflict.rs),
and
[`src/data/data_seed/timeslots.rs`](https://github.com/SolverForge/solverforge-usecases/blob/main/uc-lessons/src/data/data_seed/timeslots.rs).

- **Facts own the time data.** A `Timeslot`/`Slot` fact holds `day`, `start`,
  `end`, and a dense `index` join key. The entity's `#[planning_variable]` stores
  the slot index. Never store precomputed per-slot durations, minute offsets, or
  "allowed" booleans on the entity.
- **Generate only legal periods.** Build the slot fact list so it already fits
  the window: skip lunch breaks, stop at the day end, and — when the plan must
  not be scheduled in the past — start the horizon at "now". An illegal period
  that is never a candidate cannot be chosen. A past slot left in range will be
  chosen unless a constraint penalizes it, and a penalty can lose to a local
  optimum.
- **Fit duration to the window.** Either make slots the size the entity needs, or
  add a hard join constraint that penalizes an assignment whose duration does
  not fit the slot (`slot.end - slot.start < entity.duration`). State it, do not
  precompute it.
- **Availability lives on the fact that owns it**, as data indexed by slot
  (`Teacher.availability: Vec<bool>`, `Group.availability: Vec<bool>`). The rule
  is a join + filter, not a per-entity matrix:

  ```rust
  ConstraintFactory::<Plan, HardMediumSoftScore>::new()
      .for_each(Plan::lessons())
      .join((
          ConstraintFactory::<Plan, HardMediumSoftScore>::new().for_each(Plan::teachers()),
          equal_bi(|lesson: &Lesson| lesson.teacher_idx,
                   |teacher: &Teacher| Some(teacher.index)),
      ))
      .filter(|lesson: &Lesson, teacher: &Teacher| {
          lesson.timeslot_idx.is_some_and(|slot| {
              !teacher.availability.get(slot).copied().unwrap_or(false)
          })
      })
      .penalize(hard_weight(|_: &Lesson, _: &Teacher| HardMediumSoftScore::of_hard(1)))
      .named("Teacher Availability")
  ```

- **Overlaps: project, then self-join.** Join the entity to its slot fact, project
  only the fields needed to detect a collision, then self-join on the shared
  resource (teacher, room, group) and filter on real interval overlap. This
  handles slots and free intervals alike and scores each pair once:

  ```rust
  .join((slot_stream, equal_bi(|l: &Lesson| l.timeslot_idx,
                               |t: &Timeslot| Some(t.index))))
  .project(|lesson: &Lesson, slot: &Timeslot| AssignedSlot {
      lesson_index: lesson.index,
      teacher_idx: lesson.teacher_idx.unwrap_or(usize::MAX),
      day: slot.day_of_week, start: slot.start_time, end: slot.end_time,
  })
  .join(equal(|row: &AssignedSlot| row.teacher_idx))
  .filter(|a: &AssignedSlot, b: &AssignedSlot| {
      a.lesson_index < b.lesson_index
          && a.day == b.day
          && a.start < b.end
          && b.start < a.end
  })
  .penalize(hard_weight(|_: &AssignedSlot, _: &AssignedSlot| HardMediumSoftScore::of_hard(1)))
  .named("No Teacher Conflict")
  ```

  The self-join already visits each unordered pair exactly once, so the
  `a.lesson_index < b.lesson_index` term is only an explicit orientation guard;
  the symmetric strict `<`/`>` comparisons do the work and treat a lesson ending
  at 10:00 as compatible with one starting at 10:00.

- **Unassigned is its own constraint.** In `uc-lessons`, "every lesson gets a
  timeslot" is a **medium** constraint (a preference to schedule as much as
  possible), while availability and conflicts are hard. Use hard `.unassigned()`
  only when assignment is mandatory. Do not fold the assignment penalty into the
  conflict rules.

## Score-type caveats

- `SoftScore` has no hard level. Every constraint must be `--soft`; the CLI
  rejects hard-generated constraints on a soft-only score.
- `HardMediumSoftScore` gives you a third level for soft-quality tiers.
- Keep `.named(id)` stable: it is the identity used by `check`, `destroy`, and
  any conflict-repair selector.