From 694ef0c8179f9d43f8acf93a965fb9cc29fbaf5d Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 25 May 2026 17:10:48 +0800 Subject: [PATCH 01/50] Add /auto-pipeline orchestrator + strengthen rule-issue quality gates - New skill .claude/skills/auto-pipeline: orchestrator that drives one Backlog issue from quality gate to Final review via fresh-context subagents (check-issue, fix-issue, run-pipeline, review-pipeline). Substantive issue-body problems are routed to codex xhigh; fundamental flaws with no public reference park the issue on OnHold. - check-issue: add Rule Check 5 (Completeness, fail label "Incomplete"). Mandatory literature research + codebase corner-case enumeration + hand-tracing on >= 2 non-canonical instances for every [Rule] issue. - review-structural: add Step 4b (Round-trip Execution, mandatory for Rule reviews). Reviewer must run cargo test by name, paste the "test result: ok" line, and confirm the test exercises the four phases of a real round-trip. - review-quality: promote "closed-loop without round-trip verification" from a Minor flag to Critical, with explicit red flags (is_some-only, target-side-only asserts, unique-optimum instances). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/auto-pipeline/SKILL.md | 464 ++++++++++++++++++++++ .claude/skills/check-issue/SKILL.md | 86 +++- .claude/skills/review-quality/SKILL.md | 2 +- .claude/skills/review-structural/SKILL.md | 60 +++ 4 files changed, 608 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/auto-pipeline/SKILL.md diff --git a/.claude/skills/auto-pipeline/SKILL.md b/.claude/skills/auto-pipeline/SKILL.md new file mode 100644 index 000000000..39d6ff6f1 --- /dev/null +++ b/.claude/skills/auto-pipeline/SKILL.md @@ -0,0 +1,464 @@ +--- +name: auto-pipeline +description: Use when you want to take a Backlog issue all the way to Final review without manual orchestration — chains check-issue, fix-issue, add-model/add-rule, run-pipeline, and review-pipeline; substantive issue-quality problems are routed to codex xhigh; algorithmically unsalvageable issues are parked on OnHold +--- + +# Auto Pipeline + +Take **one** Backlog issue all the way from quality gate to **Final review** without human intervention. The merge step itself is still left to the human (see `/final-review`). + +This skill is an **orchestrator**: it never runs the heavy work itself. Each phase is delegated to a fresh-context subagent that invokes the relevant existing skill (`check-issue`, `fix-issue`, `run-pipeline`, `review-pipeline`). The only thing the main agent does directly is: + +1. pick the issue, +2. read structured reports from subagents, +3. decide whether to retry, hand off to `codex` (xhigh) for substantive rewrites, or park the issue on OnHold, +4. move the project board card forward. + +## Invocation + +- `/auto-pipeline` — pick the highest-priority Backlog issue (Good label first, then lowest issue number) +- `/auto-pipeline 123` — run on a specific Backlog issue number + +## Constants + +GitHub Project board IDs: + +| Constant | Value | +|----------|-------| +| `PROJECT_ID` | `PVT_kwDOBrtarc4BRNVy` | +| `STATUS_FIELD_ID` | `PVTSSF_lADOBrtarc4BRNVyzg_GmQc` | +| `STATUS_BACKLOG` | `ab337660` | +| `STATUS_ON_HOLD` | `48dfe446` | +| `STATUS_READY` | `f37d0d80` | +| `STATUS_IN_PROGRESS` | `a12cfc9c` | +| `STATUS_REVIEW_POOL` | `7082ed60` | +| `STATUS_FINAL_REVIEW` | `51a3d8bb` | + +## Autonomous Mode + +Runs **fully autonomously** — no confirmation prompts, no clarifying questions. All sub-skills called from here must also auto-approve. The human only gets involved at `/final-review`, or when the issue is parked on OnHold with a diagnostic comment. + +## Architecture + +```dot +digraph auto_pipeline { + rankdir=TB; + "Pick issue from Backlog" [shape=box]; + "Phase 1: check-issue (subagent)" [shape=box, style=filled, fillcolor="#cce0ff"]; + "Classify report" [shape=diamond]; + "Phase 1b: auto-fix (subagent)" [shape=box, style=filled, fillcolor="#cce0ff"]; + "Phase 1c: codex xhigh rewrite (subagent)" [shape=box, style=filled, fillcolor="#ffe0cc"]; + "Apply revised issue body" [shape=box]; + "Substantive loop counter" [shape=diamond]; + "Move to OnHold + comment" [shape=box, style=filled, fillcolor="#ffcccc"]; + "Move to Ready" [shape=box]; + "Phase 2: run-pipeline (subagent)" [shape=box, style=filled, fillcolor="#cce0ff"]; + "Phase 3: review-pipeline (subagent)" [shape=box, style=filled, fillcolor="#cce0ff"]; + "Final report" [shape=box, style=filled, fillcolor="#ccffcc"]; + + "Pick issue from Backlog" -> "Phase 1: check-issue (subagent)"; + "Phase 1: check-issue (subagent)" -> "Classify report"; + "Classify report" -> "Move to Ready" [label="pass"]; + "Classify report" -> "Phase 1b: auto-fix (subagent)" [label="mechanical only"]; + "Classify report" -> "Phase 1c: codex xhigh rewrite (subagent)" [label="substantive"]; + "Classify report" -> "Move to OnHold + comment" [label="fundamental + no reference"]; + "Phase 1b: auto-fix (subagent)" -> "Phase 1: check-issue (subagent)"; + "Phase 1c: codex xhigh rewrite (subagent)" -> "Apply revised issue body"; + "Apply revised issue body" -> "Substantive loop counter"; + "Substantive loop counter" -> "Phase 1: check-issue (subagent)" [label="< 2 retries"]; + "Substantive loop counter" -> "Move to OnHold + comment" [label=">= 2 retries"]; + "Move to Ready" -> "Phase 2: run-pipeline (subagent)"; + "Phase 2: run-pipeline (subagent)" -> "Phase 3: review-pipeline (subagent)" [label="success"]; + "Phase 2: run-pipeline (subagent)" -> "Final report" [label="fail (stop)"]; + "Phase 3: review-pipeline (subagent)" -> "Final report"; +} +``` + +## Step 0: Pick the Issue + +`scripts/pipeline_board.py backlog` only accepts `model` or `rule` (NOT `all`), and it returns `{"issue_type": ..., "items": [{number, title, item_id, labels, has_good}, ...]}`. So the picker has to query both kinds, merge, and sort `Good` first then by issue number. + +**Gotcha:** `pipeline_board.py backlog ` exits with code **1** when the kind has zero items, even though it still prints valid JSON. Do NOT pass `check=True` to `subprocess.run`; parse stdout unconditionally and ignore the return code. + +### 0a. Pick by number (if supplied) + +```bash +ISSUE= + +PICK_JSON=$(ISSUE="$ISSUE" python3 <<'PY' +import json, os, subprocess +target = int(os.environ["ISSUE"]) +hit = None +for kind in ("model", "rule"): + out = subprocess.run( + ["uv", "run", "--project", "scripts", "scripts/pipeline_board.py", + "backlog", kind, "--format", "json"], + capture_output=True, text=True + ) + try: + items = json.loads(out.stdout)["items"] + except Exception: + items = [] + for it in items: + if it["number"] == target: + hit = it + break + if hit: break +print(json.dumps(hit) if hit else "") +PY +) + +if [ -z "$PICK_JSON" ]; then + echo "Issue #$ISSUE is not in the Backlog column." + exit 0 +fi +``` + +### 0b. Pick top of Backlog (if no number supplied) + +```bash +PICK_JSON=$(python3 <<'PY' +import json, subprocess +items = [] +for kind in ("model", "rule"): + out = subprocess.run( + ["uv", "run", "--project", "scripts", "scripts/pipeline_board.py", + "backlog", kind, "--format", "json"], + capture_output=True, text=True + ) + try: + items.extend(json.loads(out.stdout)["items"]) + except Exception: + pass +if not items: + print("") +else: + # Good label first, then lowest issue number + items.sort(key=lambda i: (not i["has_good"], i["number"])) + print(json.dumps(items[0])) +PY +) + +if [ -z "$PICK_JSON" ]; then + echo "Backlog is empty." + exit 0 +fi +``` + +### 0c. Extract fields + +```bash +ISSUE=$(printf '%s' "$PICK_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['number'])") +ITEM_ID=$(printf '%s' "$PICK_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['item_id'])") +TITLE=$(printf '%s' "$PICK_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['title'])") +LABELS=$(printf '%s' "$PICK_JSON" | python3 -c "import sys,json; print(','.join(json.load(sys.stdin)['labels']))") + +echo "Auto-pipeline starting on issue #$ISSUE — $TITLE" +echo " item_id: $ITEM_ID" +echo " labels: $LABELS" +``` + +### 0d. Initialise loop counter + +```bash +SUBSTANTIVE_RETRIES=0 +MAX_SUBSTANTIVE_RETRIES=2 +``` + +## Step 1: Quality Gate (check-issue + fix loop) + +### 1a. Dispatch `check-issue` subagent + +Use the `Agent` tool with `subagent_type=general-purpose`. The subagent must run the existing `check-issue` skill (force re-check) and report back **structured JSON only**. + +**Prompt template:** + +``` +Run the repo-local /check-issue skill on GitHub issue # in the +problem-reductions repository. Read .claude/skills/check-issue/SKILL.md +and follow it exactly, including the `--force` re-check behaviour. + +For `[Rule]` issues, the Completeness check (Rule Check 5) is MANDATORY +and is the most important check — do not skip it or stub it. You must: + - find and read the cited paper or textbook section, + - quote the precise statement (theorem/precondition) you are relying on, + - enumerate the corner cases the source model allows by inspecting + `pred show --json` and the existing src/rules/*.rs files + that already reduce from the same source, + - trace the issue's algorithm by hand on at least 2 non-canonical + corner cases, and + - report the literature evidence and the traced corner cases in the + GitHub comment. + +If the cited construction is only valid under a precondition the issue +does not state, that is a substantive failure. If the cited reference +does not actually contain the reduction at all, that is a fundamental +failure with severity "fundamental". + +After it completes, return ONLY a single fenced ```json``` block with this shape: + +{ + "verdict": "pass" | "fail", + "errors": [{"check": "...", "label": "...", "summary": "...", "severity": "mechanical" | "substantive" | "fundamental"}], + "warnings": [{"check": "...", "summary": "...", "severity": "mechanical" | "substantive"}], + "fundamental_no_reference": false, + "comment_url": "" +} + +Severity rules: +- "mechanical": missing/wrong fields the issue body itself can fix without + changing the underlying claim (typos, missing G&J reference, wrong + problem alias, malformed example, wrong section heading). +- "substantive": the claim itself is wrong or unsupported (incorrect + complexity, broken overhead formula, mis-cited paper, reduction proof + sketch is flawed) but a public reference probably exists. +- "fundamental": the proposed algorithm/reduction is mathematically + unsound AND your literature search found no public reference that + would salvage it. Only use this label if you genuinely searched. + +Set "fundamental_no_reference": true if and only if at least one finding +is severity "fundamental". + +Do NOT modify any files. Do NOT post additional comments beyond what +/check-issue itself posts. Do NOT brainstorm with a user. +``` + +### 1b. Classify the report + +Parse the JSON. Then branch: + +| Condition | Action | +|---|---| +| `verdict == "pass"` | → Step 1d (move to Ready) | +| `fundamental_no_reference == true` | → Step 1e (OnHold) | +| all `errors`/`warnings` have `severity == "mechanical"` | → Step 1c-mech | +| any `severity == "substantive"` | → Step 1c-sub | + +### 1c-mech. Dispatch auto-fix subagent (mechanical only) + +``` +Run the repo-local /fix-issue skill on GitHub issue #, but in +auto-fix-only mode: + +- Only apply the mechanical auto-fixes described in fix-issue Step 3. +- Do NOT ask the human anything. +- Do NOT brainstorm substantive issues — if any remain, leave them + unchanged and report them. +- After auto-fixing, edit the issue body via `gh issue edit` as usual, + but DO NOT move the project card and DO NOT re-run /check-issue. + +Return ONLY a fenced ```json``` block: + +{ + "applied": [""], + "skipped_substantive": [""], + "errors": [""] +} +``` + +When the subagent returns, loop back to **Step 1a** (re-check). Do not increment `SUBSTANTIVE_RETRIES` — mechanical fixes are deterministic and cheap. + +### 1c-sub. Codex xhigh rewrite (substantive) + +If `SUBSTANTIVE_RETRIES >= MAX_SUBSTANTIVE_RETRIES` → jump to Step 1e (OnHold) with reason `"substantive issues persist after $MAX_SUBSTANTIVE_RETRIES codex rewrites"`. + +Otherwise, fetch the current issue body and the latest check-issue comment: + +```bash +ISSUE_BODY=$(gh issue view "$ISSUE" --json body --jq .body) +CHECK_REPORT=$(gh issue view "$ISSUE" --json comments --jq '[.comments[] | select(.body | startswith("## Issue Quality Check"))] | last | .body') +``` + +Dispatch the `codex:codex-rescue` subagent to produce a revised issue body. Brief it the way the rescue subagent expects: state goal, summarise what was tried, and ask for a concrete artefact. + +**Prompt template:** + +``` +The GitHub issue # in the problem-reductions repo failed +/check-issue with substantive issues. We need you to produce a revised +issue body that fixes those substantive problems, grounded in public +literature. + +Run `codex` non-interactively at maximum reasoning effort: + + codex exec -c model="gpt-5.4" -c model_reasoning_effort="high" --skip-git-repo-check "" + +where PROMPT_FILE contains: + + You are editing a GitHub issue that proposes a reduction rule or + problem model. The issue failed an automated quality check. Your job: + + 1. Read the original issue body (below, delimited by <<>>). + 2. Read the check-issue report (below, delimited by <<>>). + 3. For each substantive finding, decide whether a fix grounded in + public literature is possible. If yes, apply it (rewriting the + relevant section and citing the source by name + year + venue). + 4. If, after honest investigation, the underlying algorithm or + reduction is mathematically unsound and NO public reference would + salvage it, do not paper over it. Instead, return exactly: + + FUNDAMENTAL_FLAW: + + on the first line, with no markdown fences. + + Otherwise, return the full revised issue body, in the same section + structure as the original, inside a fenced ```markdown``` block. + + <<>> + + <<>> + + +After codex completes, report back ONLY one of these two JSON shapes: + + {"outcome": "revised", "new_body": ""} + +or + + {"outcome": "fundamental_flaw", "reason": ""} + +Do not edit any files yourself. +``` + +When the subagent returns: + +- **`outcome == "fundamental_flaw"`** → Step 1e (OnHold) with the reason. +- **`outcome == "revised"`** → apply the new body in the main agent (DO NOT let the subagent edit GitHub — keep edits in the orchestrator so we always know what was written): + + ```bash + printf '%s' "$NEW_BODY" > /tmp/auto-pipeline-issue-$ISSUE.md + gh issue edit "$ISSUE" --body-file /tmp/auto-pipeline-issue-$ISSUE.md + gh issue comment "$ISSUE" --body "auto-pipeline: issue body rewritten by codex xhigh (substantive retry $((SUBSTANTIVE_RETRIES + 1)))" + rm /tmp/auto-pipeline-issue-$ISSUE.md + ``` + + Increment: `SUBSTANTIVE_RETRIES=$((SUBSTANTIVE_RETRIES + 1))` and loop back to **Step 1a**. + +### 1d. Move card to Ready + +```bash +uv run --project scripts scripts/pipeline_board.py move "$ITEM_ID" ready +gh issue comment "$ISSUE" --body "auto-pipeline: quality check passed — moving to Ready." +``` + +Continue to Step 2. + +### 1e. Park on OnHold + +```bash +REASON="" +gh issue comment "$ISSUE" --body "auto-pipeline: parked on OnHold — $REASON. Human triage needed." +uv run --project scripts scripts/pipeline_board.py move "$ITEM_ID" on-hold +``` + +Print the final report and STOP: + +``` +Auto-pipeline halted at quality gate: + Issue: # + Reason: + Board: Backlog -> OnHold +``` + +## Step 2: Implementation (`run-pipeline` subagent) + +Dispatch the existing `run-pipeline` skill against the same issue: + +**Prompt template:** + +``` +Run the repo-local /run-pipeline skill on the specific issue # +(already in the Ready column). Read .claude/skills/run-pipeline/SKILL.md +and follow it exactly. The skill itself handles the worktree, the +issue-to-pr invocation, and the board moves to In Progress and Review +pool. + +After it completes, return ONLY a fenced ```json``` block: + +{ + "outcome": "success" | "failure", + "pr_number": , + "board_status": "Review pool" | "OnHold" | "", + "summary": "" +} +``` + +When the subagent returns: + +- **`outcome == "success"`** → continue to Step 3. +- **`outcome == "failure"`** → STOP. The `run-pipeline` skill already moves the card to OnHold and posts a diagnostic comment, so we do not duplicate. Print: + + ``` + Auto-pipeline halted at implementation: + Issue: # + PR: # + Reason: + Board: + ``` + + Do NOT call codex to rescue here — implementation failures are CI/code-shape problems that need human eyes. + +## Step 3: Agentic Review (`review-pipeline` subagent) + +Dispatch the existing `review-pipeline` skill against the PR: + +**Prompt template:** + +``` +Run the repo-local /review-pipeline skill on PR #. Read +.claude/skills/review-pipeline/SKILL.md and follow it exactly. It must +always move the PR to "Final review" at the end (that is the skill's +contract). + +For any PR that adds a reduction rule, the round-trip execution check +(review-structural Step 4b) is MANDATORY: + - locate the closed-loop test(s), + - actually invoke `cargo test --lib -- --exact ` and paste + the "test result: ok. N passed" line into the review, + - verify the test exercises the full round-trip — concrete non-trivial + source instance, reduce to target, solve target, extract solution + back, assert extracted source configuration is optimal (compare + against BruteForce on the source). Tests that only check + `extract_solution(...).is_some()`, only assert on target-side values, + or use an instance with a unique optimum, do NOT satisfy this. +A weak or missing round-trip is a Critical quality issue, not Minor. + +After it completes, return ONLY a fenced ```json``` block: + +{ + "outcome": "success" | "failure", + "board_status": "Final review" | "", + "review_verdicts": {"structural": "...", "quality": "...", "agentic": "..."}, + "summary": "" +} +``` + +Whatever the outcome, the PR is now either in Final review (success) or stuck somewhere the review skill left it (failure). Print the final report: + +``` +Auto-pipeline complete: + Issue: # + PR: # + Board: + Verdicts: structural=<...> quality=<...> agentic=<...> + Next: human runs /final-review +``` + +## Reporting Contract + +Every subagent dispatched by this skill MUST return a single fenced ```json``` block as the last thing in its message. The main agent parses only that block. If a subagent returns malformed JSON: + +1. Re-dispatch once with the prompt prefixed by `Your previous reply did not contain a parseable ```json``` block as required. Run the skill again from scratch and return ONLY the JSON block.` +2. If the second attempt also fails, park the issue on OnHold with reason `subagent contract violation in `. + +## Common Mistakes + +| Mistake | Fix | +|---------|-----| +| Calling sub-skills directly in the main agent | Always dispatch via `Agent` tool — keeps the orchestrator context clean | +| Looping codex more than 2 times on substantive issues | Hard cap at 2 retries; park on OnHold afterwards | +| Letting the codex subagent edit GitHub | The orchestrator owns all `gh issue edit` calls — codex only returns text | +| Treating implementation failures as substantive issue problems | Step 2 failures go straight to a stop; they are not eligible for codex rescue | +| Skipping the re-check after auto-fix | Always re-run check-issue after either mechanical or substantive fixes | +| Forgetting to increment `SUBSTANTIVE_RETRIES` | Only substantive rewrites count toward the cap; mechanical fixes do not | +| Picking from a non-Backlog column when no issue number is given | Auto-pick must read from Backlog only — never from OnHold, Ready, or elsewhere | diff --git a/.claude/skills/check-issue/SKILL.md b/.claude/skills/check-issue/SKILL.md index cfa297aef..62e2b3bc7 100644 --- a/.claude/skills/check-issue/SKILL.md +++ b/.claude/skills/check-issue/SKILL.md @@ -199,6 +199,81 @@ If the algorithm is a high-level sketch rather than an implementable procedure --- +## Rule Check 5: Completeness (fail label: `Incomplete`) + +**Goal:** Does the proposed mapping work for *every* instance of the source problem, not just the canonical case in the example? + +A reduction that only handles a subset of source instances (e.g., "assumes connected graph", "assumes no duplicate elements", "works only when k is even") is **not a valid polynomial-time reduction** unless the issue explicitly: +- restricts the source to a sub-variant that the codebase actually exposes as a distinct model, AND +- the restriction is part of the algorithm statement, not a hidden assumption. + +This check is **mandatory** for every `[Rule]` issue and must be backed by **explicit literature and codebase research** — not vibes. + +### 5a: Literature research (mandatory) + +For the cited construction(s): + +1. Find the original paper or textbook section that defines the reduction. +2. Read the **statement of the theorem**: what does it claim the reduction handles? Look for phrases like: + - "for any instance of P" → covers all instances (good) + - "for a P-instance such that ..." → has a precondition (must be flagged) + - "in the special case where ..." → only a special case (must be flagged) +3. Read the **proof**: are there steps that silently assume something about the source (no isolated vertices, no zero weights, integer-valued capacities, ...)? + +Use the same fallback chain as Check 3c (project bibliography → arxiv MCP → Semantic Scholar MCP → WebSearch + WebFetch). + +If the cited paper is **not actually a reduction from the full source problem** but from a restricted variant → **Fail** with the precise restriction quoted from the paper. The fix is one of: (a) add a preprocessing step that reduces the full source to the restricted variant, (b) split into a `[Rule]` issue from the actual restricted source, or (c) drop the reduction. + +### 5b: Codebase corner-case research (mandatory) + +Check the actual codebase to see what shape the source problem can take: + +```bash +pred show --json +``` + +Read the `size_fields` and any variant getters, then enumerate corner cases the issue's algorithm must handle: + +| Class | Example corner cases the reduction must accept | +|---|---| +| Graph-input problems | empty graph, single vertex, isolated vertices, self-loops if the model allows them, parallel edges if allowed, disconnected components, complete graph | +| Weighted problems | all weights equal, all weights zero, mixed signs (if the weight type allows), one weight dominating the rest | +| Formula/circuit | empty clause set, single-literal clauses, tautological clauses, repeated variables in a clause | +| Set systems | empty universe, empty subsets, identical subsets, universe element appearing in no subset | +| Algebraic | zero matrix, identity, singular matrix | + +Then trace the **issue's** algorithm by hand against at least 2 corner cases that are not the worked example: + +1. Pick a corner case from the table above that the source model actually allows. +2. Simulate the issue's construction step by step. +3. Check: is the target problem well-defined? Does solution extraction still work? + +Also grep the codebase for any existing rule whose source has the same problem name — if it already handles certain corner cases, the new rule should at least match that coverage: + +```bash +grep -rl "impl.*ReduceTo.*for " src/rules/ +``` + +Read 1–2 of those existing rules for how they handle edge inputs. + +If the issue's algorithm **crashes, produces an invalid target instance, or loses information** on a legitimate corner case → **Fail** with the corner case spelled out. + +If the issue's algorithm appears to handle corner cases correctly but the issue body doesn't *state* this explicitly → **Warn** ("works on tested corner cases, but the algorithm description does not address edge inputs — please document"). + +### 5c: Verdict + +| Finding | Verdict | +|---|---| +| Literature explicitly covers all instances AND traced corner cases work | **Pass** | +| Literature explicitly covers all instances but issue is silent on corner cases | **Warn** | +| Literature has a precondition the issue ignores | **Fail (severity: substantive)** | +| Traced corner case breaks the algorithm | **Fail (severity: substantive)** | +| Cited reference does not actually contain the reduction | **Fail (severity: fundamental)** — this overlaps with Check 3c, flag both | + +Report the literature evidence and the corner cases you traced in the comment — this is the most expensive check and reviewers will want to see your work. + +--- + # Part B: Model Issue Checks Applies when the title contains `[Model]`. @@ -359,9 +434,10 @@ Post a single GitHub comment. The table adapts to the issue type: | Usefulness | ✅ Pass | No existing direct reduction Source → Target | | Non-trivial | ✅ Pass | Gadget construction with penalty terms | | Correctness | ❌ Fail | Paper "Smith 2020" not found on arxiv or Semantic Scholar | +| Completeness | ⚠️ Warn | Algorithm correct on traced corner cases but issue body silent on edge inputs | | Well-written | ⚠️ Warn | Symbol `m` used in overhead table but not defined in algorithm | -**Overall: 2 passed, 1 failed, 1 warning** +**Overall: 2 passed, 1 failed, 2 warnings** --- @@ -374,6 +450,9 @@ Post a single GitHub comment. The table adapts to the issue type: ### Correctness [Per-reference verification results, any better algorithms found] +### Completeness +[Literature passages cited (with quote + section/theorem number) showing whether the construction covers all source instances, and the corner cases you traced by hand with the algorithm — including any that broke or any preconditions you discovered] + ### Well-written [Specific items to fix] @@ -423,13 +502,14 @@ gh issue edit --add-label "Useless" # if Check 1 failed gh issue edit --add-label "Trivial" # if Check 2 failed gh issue edit --add-label "Wrong" # if Check 3 failed gh issue edit --add-label "PoorWritten" # if Check 4 failed +gh issue edit --add-label "Incomplete" # if Rule Check 5 failed -# "Good" label requires: zero failures AND zero warnings on Usefulness or Correctness. +# "Good" label requires: zero failures AND zero warnings on Usefulness, Correctness, or Completeness. # Warnings on Non-trivial or Well-written alone do NOT block "Good". gh issue edit --add-label "Good" # If re-checking after fixes, remove stale failure labels and add "Good" if now passing -gh issue edit --remove-label "Useless,Trivial,Wrong,PoorWritten" 2>/dev/null +gh issue edit --remove-label "Useless,Trivial,Wrong,PoorWritten,Incomplete" 2>/dev/null gh issue edit --add-label "Good" ``` diff --git a/.claude/skills/review-quality/SKILL.md b/.claude/skills/review-quality/SKILL.md index 7c43c8241..28e7d89e3 100644 --- a/.claude/skills/review-quality/SKILL.md +++ b/.claude/skills/review-quality/SKILL.md @@ -72,7 +72,7 @@ Flag tests that: - **Mirror the implementation**: Tests recomputing the same formula as the code prove nothing - **Lack adversarial cases**: Only happy path. Tests must include infeasible configs and boundary cases - **Use trivial instances only**: Single-edge or 2-node tests may pass with bugs. Need 5+ vertex instances -- **Closed-loop without verification**: Must verify extracted solution is **optimal** (compare brute-force on both source and target) +- **Closed-loop without round-trip verification (CRITICAL for `[Rule]` PRs)**: For any new reduction rule, the test suite MUST include at least one test that (a) builds a non-trivial source instance, (b) calls `ReduceTo::::reduce_to(&source)`, (c) solves the target, (d) calls `extract_solution` to map the witness back, and (e) asserts the extracted source configuration is **optimal** (compare against `BruteForce::find_witness(&source)` or `assert_optimization_round_trip_from_optimization_target`). Tests that only assert on target-side values, or only check `extract_solution(...).is_some()`, or use a source instance whose optimum is unique by inspection, do NOT count. Missing or weak round-trip coverage is a **Critical** issue, not Minor. - **Assert count too low**: 1-2 asserts for non-trivial code is insufficient ## Output Format diff --git a/.claude/skills/review-structural/SKILL.md b/.claude/skills/review-structural/SKILL.md index 5c30e15b6..fb04cac56 100644 --- a/.claude/skills/review-structural/SKILL.md +++ b/.claude/skills/review-structural/SKILL.md @@ -118,6 +118,60 @@ Report pass/fail. If tests fail, identify which tests. **Do NOT fix anything** 3. **Example quality** — Is it tutorial-style? Does the JSON export include both source and target data? 4. **Paper quality** — Is the reduction-rule statement precise? Is the proof sketch sound? +## Step 4b: Round-trip Execution (Rule reviews only) — MANDATORY + +Run the rule **end to end** on its canonical example(s), not just confirm a `closed_loop` test exists. The goal is to catch reductions that compile but silently lose information or drop corner cases. + +### 4b-1: Locate the test + +```bash +# The closed-loop test for this rule. The reference helper is: +# crate::rules::test_helpers::assert_optimization_round_trip_from_optimization_target +# Find every test in the new test file that exercises the round-trip: +grep -nE "fn test_.*(closed_loop|round_trip|jl_parity)" src/unit_tests/rules/{R}.rs +``` + +### 4b-2: Run it by name and confirm it actually executes + +```bash +cargo test --lib --no-fail-fast -- --exact test_{rule_module}::test_{...}_closed_loop +``` + +- Confirm the binary prints `test result: ok. 1 passed` for **each** named test. +- "0 passed; 0 failed; 0 ignored" means the test name was wrong → flag as ISSUE. +- If the closed-loop test is `#[ignore]`'d → FAIL. + +### 4b-3: Confirm the round-trip is real, not a type check + +Read each closed-loop test and verify it does ALL FOUR of the following: + +| # | What the test must do | Red flag | +|---|---|---| +| 1 | Build a concrete source instance with **multiple feasible solutions of different objective values** | Trivial instance (single edge, single clause, empty graph) — anything where the answer is unique by inspection | +| 2 | Call `ReduceTo::::reduce_to(&source)` to produce the target | Test only asserts on `target_problem()` fields without actually solving | +| 3 | Solve the target (BruteForce / ILP / appropriate solver) AND solve the source independently | Test compares only target value, never source value | +| 4 | Use `extract_solution` (or the appropriate helper, e.g. `assert_optimization_round_trip_from_optimization_target`) to map the target witness back, then assert the extracted source configuration is **optimal** for the source | Test asserts only that `extract_solution` returns `Some(_)` or has the right length | + +If any of (1)-(4) is missing → ISSUE (test does not verify the round-trip). + +### 4b-4: Spot-check with `pred` on a fresh instance + +Pick **one** instance from the rule's canonical example (loadable via `pred create --example `) OR construct one by hand. Then drive it through the CLI: + +```bash +# 1. Solve source directly +pred create --output /tmp/src.json +pred solve /tmp/src.json + +# 2. Reduce to target, solve target, extract solution back to source +pred path --json # confirm the new rule is on the chosen path +pred solve /tmp/src.json --via # if your build supports --via; otherwise use the lib API +``` + +Confirm the source-side objective value reported via the reduction matches the direct-solve value. If they disagree → critical ISSUE (the reduction is unsound or `extract_solution` is wrong). If `--via` is not wired for this path, write a short Rust scratch in a `cargo test --lib --no-fail-fast --test main` invocation, OR fall back to reading the closed-loop test and stating explicitly that 4b-2 already covered this — but you must say which. + +Report which path was used (Rust test, `pred --via`, hand-written scratch) so reviewers can reproduce. + ## Step 5: Issue Compliance Review Only if a linked issue was provided. @@ -163,6 +217,12 @@ Flag any deviation as ISSUE. ### Semantic Review - [check]: OK / ISSUE — description +### Round-trip Execution (rules only) +- Closed-loop test located: PASS / FAIL — name(s) and file +- Closed-loop test runs and passes: PASS / FAIL — paste the `test result: ok. N passed` line +- Test exercises real round-trip (4 criteria): PASS / FAIL — note any missing criterion +- Spot-check with pred / scratch: PASS / FAIL — record method used and observed values + ### Issue Compliance (if linked issue found) | # | Check | Status | |---|-------|--------| From 41972d8a0ff5504af92ca6cb9e55e9518112658e Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 25 May 2026 17:45:30 +0800 Subject: [PATCH 02/50] Add MinimumDiscretePlanarInverseKinematics model (#994) Robotics inverse-kinematics problem: given link lengths l_j, target g in R^2, per-link sampled orientations Phi_j, and consecutive-pair admissibility sets A_j, pick indices a_j in {0..m_j-1} with (a_{j-1}, a_j) in A_j minimizing the squared end-effector distance ||sum_j l_j (cos phi_{j,a_j}, sin phi_{j,a_j}) - g||^2. - src/models/misc/minimum_discrete_planar_inverse_kinematics.rs: per-link dims (non-binary), Min objective, A_j feasibility returns Min(None), declare_variants! default entry, ProblemSchemaEntry + ProblemSizeFieldEntry, canonical example_db spec via inventory. - src/unit_tests/models/misc/...: creation, evaluate (feasible/ infeasible), brute-force solver, serialization roundtrip. - problemreductions-cli/: new (f64,f64) and Vec> schema parsers; --link-lengths/--target-point/--orientation-samples/ --allowed-pairs flags via the schema-driven create path. - docs/paper: problem-def block + display-name + worked example; references.bib entries for Salloum2025 and DaiIzattTedrake2019. Reference: Salloum et al., "Quantum annealing for inverse kinematics in robotics", Scientific Reports 2025, doi:10.1038/s41598-025-34346-z. Closes #994 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/paper/reductions.typ | 35 +++ docs/paper/references.bib | 22 ++ problemreductions-cli/src/cli.rs | 16 ++ problemreductions-cli/src/commands/create.rs | 4 + .../src/commands/create/schema_support.rs | 48 ++++ .../src/commands/create/tests.rs | 4 + ...imum_discrete_planar_inverse_kinematics.rs | 265 ++++++++++++++++++ src/models/misc/mod.rs | 4 + ...imum_discrete_planar_inverse_kinematics.rs | 114 ++++++++ 9 files changed, 512 insertions(+) create mode 100644 src/models/misc/minimum_discrete_planar_inverse_kinematics.rs create mode 100644 src/unit_tests/models/misc/minimum_discrete_planar_inverse_kinematics.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 449db0e57..353ab77e9 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -293,6 +293,7 @@ "MultipleChoiceBranching": [Multiple Choice Branching], "MultipleCopyFileAllocation": [Multiple Copy File Allocation], "ExpectedRetrievalCost": [Expected Retrieval Cost], + "MinimumDiscretePlanarInverseKinematics": [Minimum Discrete Planar Inverse Kinematics], "MultiprocessorScheduling": [Multiprocessor Scheduling], "NonLivenessFreePetriNet": [Non-Liveness Free Petri Net], "ProductionPlanning": [Production Planning], @@ -5591,6 +5592,40 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| ] } +#{ + let x = load-model-example("MinimumDiscretePlanarInverseKinematics") + [ + #problem-def("MinimumDiscretePlanarInverseKinematics")[ + Given positive link lengths $l_1, dots, l_n$, a target point $g = (g_x, g_y) in bb(R)^2$, finite sets of candidate absolute orientations $Phi_j = {phi_(j,0), dots, phi_(j,m_j-1)}$ for every link $j = 1, dots, n$, and admissible pair sets $A_j subset.eq {0, dots, m_(j-1) - 1} times {0, dots, m_j - 1}$ for $j = 2, dots, n$, choose indices $a_j in {0, dots, m_j - 1}$ such that $(a_(j-1), a_j) in A_j$ for every $j = 2, dots, n$, minimizing the squared end-effector error + $ norm(sum_(j=1)^n l_j (cos phi_(j,a_j), sin phi_(j,a_j)) - g)_2^2. $ + ][ + The Minimum Discrete Planar Inverse Kinematics problem is the discrete-sample reformulation of planar inverse kinematics studied by Salloum, Savin, Kholodov, Ryzhakov, Farina, and Oseledets @salloum2025ikqubo for quantum annealing pipelines. Each link is parameterized by its absolute orientation rather than a local joint angle, so the workspace position is linear in the per-link selector variables and one-hot encoding produces a genuinely quadratic objective when reducing to QUBO. The admissible pair sets $A_j$ model joint limits on the relative angle $phi_(j,a_j) - phi_(j-1,a_(j-1))$. The decision and exact optimization versions are NP-hard via Knapsack-style packing of discretized angle choices, mirroring the broader mixed-integer convex inverse-kinematics formulation of Dai, Izatt, and Tedrake @daiizatttedrake2019. The registered exact baseline enumerates the product domain $product_(j=1)^n m_j$#footnote[No algorithm improving on exhaustive enumeration over per-link discrete orientations is known for this discretized formulation in general.]. + + *Example.* Take $n = 2$ with link lengths $(l_1, l_2) = (2, 1)$, target $g = (2, 1)$, sampled orientations $Phi_1 = Phi_2 = {0, pi / 2}$, and admissible pair set $A_2 = {(0,0), (0,1), (1,1)}$. The configuration $(a_1, a_2) = (0, 1)$ lies in $A_2$ and places the end-effector at $(2 cos 0, 2 sin 0) + (cos(pi / 2), sin(pi / 2)) = (2, 1)$, giving optimal objective value $0$. The other two feasible configurations have squared errors $2$ (for $(0,0)$) and $8$ (for $(1,1)$); the configuration $(1, 0)$ is infeasible. + + #pred-commands( + "pred create --example MinimumDiscretePlanarInverseKinematics -o ik.json", + "pred solve ik.json --solver brute-force", + "pred evaluate ik.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure( + table( + columns: 4, + inset: 6pt, + stroke: 0.5pt + luma(180), + [Config $(a_1, a_2)$], [Feasible?], [End-effector], [Squared error], + [$(0, 0)$], [yes], [$(3, 0)$], [$2$], + [$(0, 1)$], [yes (optimal)], [$(2, 1)$], [$0$], + [$(1, 0)$], [no], [---], [---], + [$(1, 1)$], [yes], [$(0, 3)$], [$8$], + ), + caption: [Minimum Discrete Planar Inverse Kinematics example with $n = 2$, link lengths $(2, 1)$, target $(2, 1)$, and per-link orientations ${0, pi / 2}$. Three of the four configurations are admissible under $A_2 = {(0,0), (0,1), (1,1)}$; the unique optimum is $(0, 1)$ with squared error $0$.], + ) + ] + ] +} + #{ let x = load-model-example("BMF") let mr = x.instance.m diff --git a/docs/paper/references.bib b/docs/paper/references.bib index bb99fc9c0..a9eb68d98 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -1845,3 +1845,25 @@ @inproceedings{lovasz1973 pages = {3--12}, year = {1973} } + +@article{salloum2025ikqubo, + author = {Hadi Salloum and Sergei Savin and Yaroslav Kholodov and Gleb Ryzhakov and Mirko Farina and Ivan Oseledets}, + title = {Quantum annealing for inverse kinematics in robotics}, + journal = {Scientific Reports}, + volume = {16}, + number = {1}, + pages = {4244}, + year = {2025}, + doi = {10.1038/s41598-025-34346-z} +} + +@article{daiizatttedrake2019, + author = {Hongkai Dai and Gregory Izatt and Russ Tedrake}, + title = {Global inverse kinematics via mixed-integer convex optimization}, + journal = {The International Journal of Robotics Research}, + volume = {38}, + number = {12--13}, + pages = {1420--1441}, + year = {2019}, + doi = {10.1177/0278364919846512} +} diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index f401759b1..730117171 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -566,6 +566,18 @@ pub struct CreateArgs { /// Record access probabilities for ExpectedRetrievalCost (comma-separated, e.g., "0.2,0.15,0.15,0.2,0.1,0.2") #[arg(long)] pub probabilities: Option, + /// Link lengths for MinimumDiscretePlanarInverseKinematics (comma-separated positive reals, e.g., "2.0,1.0") + #[arg(long)] + pub link_lengths: Option, + /// Target point (x,y) for MinimumDiscretePlanarInverseKinematics (e.g., "2.0,1.0") + #[arg(long)] + pub target_point: Option, + /// Sampled absolute orientations per link for MinimumDiscretePlanarInverseKinematics (semicolon-separated angle lists, e.g., "0.0,1.5707963267948966;0.0,1.5707963267948966") + #[arg(long)] + pub orientation_samples: Option, + /// Admissible (a_{j-1}, a_j) pair sets per junction for MinimumDiscretePlanarInverseKinematics (pipe-separated junctions, each comma-separated "i-j" pairs, e.g., "0-0,0-1,1-1") + #[arg(long)] + pub allowed_pairs: Option, /// Bin capacity for BinPacking #[arg(long)] pub capacity: Option, @@ -988,6 +1000,10 @@ impl CreateArgs { insert!("requirement-2", self.requirement_2); insert!("sizes", self.sizes.as_deref()); insert!("probabilities", self.probabilities.as_deref()); + insert!("link-lengths", self.link_lengths.as_deref()); + insert!("target-point", self.target_point.as_deref()); + insert!("orientation-samples", self.orientation_samples.as_deref()); + insert!("allowed-pairs", self.allowed_pairs.as_deref()); insert!("capacity", self.capacity.as_deref()); insert!("sequence", self.sequence.as_deref()); insert!("subsets", self.sets.as_deref()); diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 4b8b88232..cd460f46e 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -163,6 +163,10 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.alphabet_size.is_none() && args.num_groups.is_none() && args.num_sectors.is_none() + && args.link_lengths.is_none() + && args.target_point.is_none() + && args.orientation_samples.is_none() + && args.allowed_pairs.is_none() && args.dependencies.is_none() && args.num_attributes.is_none() && args.source_string.is_none() diff --git a/problemreductions-cli/src/commands/create/schema_support.rs b/problemreductions-cli/src/commands/create/schema_support.rs index 4d6587bc4..b3dee9fe8 100644 --- a/problemreductions-cli/src/commands/create/schema_support.rs +++ b/problemreductions-cli/src/commands/create/schema_support.rs @@ -801,6 +801,8 @@ pub(super) fn parse_field_value( "Vec<(usize,Vec)>" => parse_indexed_usize_lists_value(raw)?, "Vec>" => serde_json::to_value(parse_job_shop_jobs(raw)?)?, "Vec<(f64,f64)>" => serde_json::to_value(util::parse_positions::(raw, "0.0,0.0")?)?, + "(f64,f64)" => parse_f64_pair_value(raw)?, + "Vec>" => parse_nested_pair_list_value(raw)?, "Vec" => { serde_json::to_value(parse_cdft_frequency_tables_value(raw, context)?)? } @@ -944,6 +946,52 @@ pub(super) fn parse_pair_list_value(raw: &str) -> Result { Ok(serde_json::to_value(pairs)?) } +pub(super) fn parse_f64_pair_value(raw: &str) -> Result { + let parts: Vec<&str> = raw.split(',').collect(); + anyhow::ensure!( + parts.len() == 2, + "Invalid (f64,f64) pair '{}': expected format x,y (e.g., 2.0,1.0)", + raw.trim() + ); + let x: f64 = parts[0] + .trim() + .parse() + .map_err(|err| anyhow::anyhow!("Invalid x in '{}': {err}", raw.trim()))?; + let y: f64 = parts[1] + .trim() + .parse() + .map_err(|err| anyhow::anyhow!("Invalid y in '{}': {err}", raw.trim()))?; + Ok(serde_json::to_value((x, y))?) +} + +pub(super) fn parse_nested_pair_list_value(raw: &str) -> Result { + let groups: Vec> = raw + .split('|') + .map(|group| { + let trimmed = group.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + trimmed + .split(',') + .map(|entry| { + let entry = entry.trim(); + let parts: Vec<&str> = entry.split('-').collect(); + anyhow::ensure!( + parts.len() == 2, + "Invalid pair '{entry}': expected i-j (e.g., 0-1)" + ); + Ok(( + parts[0].trim().parse::()?, + parts[1].trim().parse::()?, + )) + }) + .collect::>>() + }) + .collect::>()?; + Ok(serde_json::to_value(groups)?) +} + pub(super) fn infer_cbq_num_variables(raw: &str) -> Result { let mut num_vars = 0usize; for conjunct in raw.split(';').filter(|entry| !entry.trim().is_empty()) { diff --git a/problemreductions-cli/src/commands/create/tests.rs b/problemreductions-cli/src/commands/create/tests.rs index 04ed464ff..88fdd5775 100644 --- a/problemreductions-cli/src/commands/create/tests.rs +++ b/problemreductions-cli/src/commands/create/tests.rs @@ -1629,6 +1629,10 @@ fn empty_args() -> CreateArgs { requirement_2: None, sizes: None, probabilities: None, + link_lengths: None, + target_point: None, + orientation_samples: None, + allowed_pairs: None, capacity: None, sequence: None, sets: None, diff --git a/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs b/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs new file mode 100644 index 000000000..e0f6797e2 --- /dev/null +++ b/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs @@ -0,0 +1,265 @@ +//! Minimum Discrete Planar Inverse Kinematics problem implementation. +//! +//! Given positive link lengths, a target point in `R^2`, a finite set of +//! candidate absolute orientations per link, and admissible-pair sets between +//! consecutive links, choose one orientation index per link so that all +//! consecutive-pair constraints are satisfied and the squared distance from +//! the end-effector to the target point is minimized. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::traits::Problem; +use crate::types::Min; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "MinimumDiscretePlanarInverseKinematics", + display_name: "Minimum Discrete Planar Inverse Kinematics", + aliases: &[], + dimensions: &[], + module_path: module_path!(), + description: "Pick one sampled absolute orientation per link, subject to consecutive-pair feasibility constraints, to minimize the squared distance from the end-effector to a target point", + fields: &[ + FieldInfo { + name: "link_lengths", + type_name: "Vec", + description: "Positive link lengths l_1, ..., l_n", + }, + FieldInfo { + name: "target_point", + type_name: "(f64, f64)", + description: "Target point g = (g_x, g_y) in R^2", + }, + FieldInfo { + name: "orientation_samples", + type_name: "Vec>", + description: "Sampled absolute orientations Phi_j for each link j", + }, + FieldInfo { + name: "allowed_pairs", + type_name: "Vec>", + description: "Admissible (a_{j-1}, a_j) pair sets A_j for j = 2, ..., n", + }, + ], + } +} + +inventory::submit! { + ProblemSizeFieldEntry { + name: "MinimumDiscretePlanarInverseKinematics", + fields: &["num_links"], + } +} + +/// The Minimum Discrete Planar Inverse Kinematics problem. +/// +/// Given positive link lengths `l_1, ..., l_n`, a target point `g` in +/// `R^2`, sampled absolute orientations `Phi_j = {phi_{j,0}, ..., phi_{j,m_j-1}}` +/// for each link, and admissible-pair sets +/// `A_j ⊆ {0, ..., m_{j-1}-1} x {0, ..., m_j-1}` for `j = 2, ..., n`, +/// choose indices `a_j ∈ {0, ..., m_j-1}` such that `(a_{j-1}, a_j) ∈ A_j` +/// for every `j = 2, ..., n`, minimizing +/// +/// `|| Σ_{j=1}^n l_j (cos(phi_{j,a_j}), sin(phi_{j,a_j})) - g ||_2^2`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MinimumDiscretePlanarInverseKinematics { + link_lengths: Vec, + target_point: (f64, f64), + orientation_samples: Vec>, + allowed_pairs: Vec>, +} + +impl MinimumDiscretePlanarInverseKinematics { + /// Construct a new instance. + /// + /// # Panics + /// Panics if the input fields are not mutually consistent (see the + /// validation rules in the source). + pub fn new( + link_lengths: Vec, + target_point: (f64, f64), + orientation_samples: Vec>, + allowed_pairs: Vec>, + ) -> Self { + let n = link_lengths.len(); + assert!( + n >= 1, + "MinimumDiscretePlanarInverseKinematics requires at least one link" + ); + for &length in &link_lengths { + assert!( + length.is_finite() && length > 0.0, + "link lengths must be positive finite reals" + ); + } + assert!( + target_point.0.is_finite() && target_point.1.is_finite(), + "target point coordinates must be finite reals" + ); + assert_eq!( + orientation_samples.len(), + n, + "orientation_samples must have one entry per link" + ); + for samples in &orientation_samples { + assert!( + !samples.is_empty(), + "each link must have at least one candidate orientation" + ); + for &angle in samples { + assert!( + angle.is_finite(), + "orientation samples must be finite real numbers" + ); + } + } + assert_eq!( + allowed_pairs.len(), + n.saturating_sub(1), + "allowed_pairs must have one entry per junction (n - 1 entries)" + ); + for (j_minus_1, pairs) in allowed_pairs.iter().enumerate() { + let m_prev = orientation_samples[j_minus_1].len(); + let m_curr = orientation_samples[j_minus_1 + 1].len(); + for &(a_prev, a_curr) in pairs { + assert!( + a_prev < m_prev, + "allowed_pair index out of range for previous link" + ); + assert!( + a_curr < m_curr, + "allowed_pair index out of range for current link" + ); + } + } + Self { + link_lengths, + target_point, + orientation_samples, + allowed_pairs, + } + } + + /// Get the link lengths. + pub fn link_lengths(&self) -> &[f64] { + &self.link_lengths + } + + /// Get the target point. + pub fn target_point(&self) -> (f64, f64) { + self.target_point + } + + /// Get the per-link orientation samples. + pub fn orientation_samples(&self) -> &[Vec] { + &self.orientation_samples + } + + /// Get the admissible-pair sets for consecutive junctions. + pub fn allowed_pairs(&self) -> &[Vec<(usize, usize)>] { + &self.allowed_pairs + } + + /// Number of links `n`. + pub fn num_links(&self) -> usize { + self.link_lengths.len() + } + + /// Check if a configuration is feasible (one index per link, in range, + /// and every consecutive pair lies in the corresponding admissible set). + pub fn is_feasible(&self, config: &[usize]) -> bool { + let n = self.num_links(); + if config.len() != n { + return false; + } + for (j, &a) in config.iter().enumerate() { + if a >= self.orientation_samples[j].len() { + return false; + } + } + for j in 1..n { + let pair = (config[j - 1], config[j]); + if !self.allowed_pairs[j - 1].contains(&pair) { + return false; + } + } + true + } + + /// Compute the end-effector position for a configuration. + /// Returns `None` if the configuration is infeasible. + pub fn end_effector(&self, config: &[usize]) -> Option<(f64, f64)> { + if !self.is_feasible(config) { + return None; + } + let mut x = 0.0_f64; + let mut y = 0.0_f64; + for (j, &a) in config.iter().enumerate() { + let phi = self.orientation_samples[j][a]; + x += self.link_lengths[j] * phi.cos(); + y += self.link_lengths[j] * phi.sin(); + } + Some((x, y)) + } + + /// Compute the squared end-effector distance to the target. + /// Returns `None` if the configuration is infeasible. + pub fn squared_distance(&self, config: &[usize]) -> Option { + let (x, y) = self.end_effector(config)?; + let dx = x - self.target_point.0; + let dy = y - self.target_point.1; + Some(dx * dx + dy * dy) + } + + /// Whether the configuration represents a valid feasible solution. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + self.is_feasible(config) + } +} + +impl Problem for MinimumDiscretePlanarInverseKinematics { + const NAME: &'static str = "MinimumDiscretePlanarInverseKinematics"; + type Value = Min; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + self.orientation_samples + .iter() + .map(|samples| samples.len()) + .collect() + } + + fn evaluate(&self, config: &[usize]) -> Min { + match self.squared_distance(config) { + Some(value) => Min(Some(value)), + None => Min(None), + } + } +} + +crate::declare_variants! { + default MinimumDiscretePlanarInverseKinematics => "2^num_links", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + use std::f64::consts::FRAC_PI_2; + vec![crate::example_db::specs::ModelExampleSpec { + id: "minimum_discrete_planar_inverse_kinematics", + instance: Box::new(MinimumDiscretePlanarInverseKinematics::new( + vec![2.0, 1.0], + (2.0, 1.0), + vec![vec![0.0, FRAC_PI_2], vec![0.0, FRAC_PI_2]], + vec![vec![(0, 0), (0, 1), (1, 1)]], + )), + optimal_config: vec![0, 1], + optimal_value: serde_json::json!(0.0), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/minimum_discrete_planar_inverse_kinematics.rs"] +mod tests; diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index ee7e2e658..fc8053a0e 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -29,6 +29,7 @@ //! - [`MinimumCodeGenerationOneRegister`]: Minimize instruction count for a one-register machine //! - [`MinimumCodeGenerationParallelAssignments`]: Minimize backward dependencies when ordering parallel assignments //! - [`MinimumCodeGenerationUnlimitedRegisters`]: Minimize instruction count for an unlimited-register machine with 2-address instructions +//! - [`MinimumDiscretePlanarInverseKinematics`]: Pick one sampled absolute orientation per planar link to reach a target point under consecutive-pair feasibility //! - [`MinimumExternalMacroDataCompression`]: Minimize compression cost using external dictionary //! - [`MinimumFaultDetectionTestSet`]: Find minimum set of input-output paths covering all internal DAG vertices //! - [`MinimumInternalMacroDataCompression`]: Minimize self-referencing compression cost @@ -137,6 +138,7 @@ mod minimum_code_generation_one_register; pub(crate) mod minimum_code_generation_parallel_assignments; mod minimum_code_generation_unlimited_registers; pub(crate) mod minimum_decision_tree; +pub(crate) mod minimum_discrete_planar_inverse_kinematics; pub(crate) mod minimum_disjunctive_normal_form; mod minimum_external_macro_data_compression; mod minimum_fault_detection_test_set; @@ -210,6 +212,7 @@ pub use minimum_code_generation_one_register::MinimumCodeGenerationOneRegister; pub use minimum_code_generation_parallel_assignments::MinimumCodeGenerationParallelAssignments; pub use minimum_code_generation_unlimited_registers::MinimumCodeGenerationUnlimitedRegisters; pub use minimum_decision_tree::MinimumDecisionTree; +pub use minimum_discrete_planar_inverse_kinematics::MinimumDiscretePlanarInverseKinematics; pub use minimum_disjunctive_normal_form::MinimumDisjunctiveNormalForm; pub use minimum_external_macro_data_compression::MinimumExternalMacroDataCompression; pub use minimum_fault_detection_test_set::MinimumFaultDetectionTestSet; @@ -307,6 +310,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec MinimumDiscretePlanarInverseKinematics { + MinimumDiscretePlanarInverseKinematics::new( + vec![2.0, 1.0], + (2.0, 1.0), + vec![vec![0.0, FRAC_PI_2], vec![0.0, FRAC_PI_2]], + vec![vec![(0, 0), (0, 1), (1, 1)]], + ) +} + +#[test] +fn test_minimum_discrete_planar_inverse_kinematics_creation() { + let problem = sample_problem(); + assert_eq!(problem.num_links(), 2); + assert_eq!(problem.link_lengths(), &[2.0, 1.0]); + assert_eq!(problem.target_point(), (2.0, 1.0)); + assert_eq!(problem.orientation_samples().len(), 2); + assert_eq!(problem.allowed_pairs().len(), 1); + assert_eq!(problem.dims(), vec![2, 2]); + assert_eq!(problem.num_variables(), 2); +} + +#[test] +fn test_minimum_discrete_planar_inverse_kinematics_evaluate_feasible() { + let problem = sample_problem(); + + // [0, 1] -> end-effector (2, 1), distance^2 = 0. + let value = problem.evaluate(&[0, 1]); + assert!(matches!(value, Min(Some(v)) if v.abs() < EPS)); + assert!(problem.is_valid_solution(&[0, 1])); + + // [0, 0] -> end-effector (3, 0), distance^2 = (3-2)^2 + (0-1)^2 = 2. + let value = problem.evaluate(&[0, 0]); + assert!(matches!(value, Min(Some(v)) if (v - 2.0).abs() < EPS)); + + // [1, 1] -> end-effector (0, 3), distance^2 = (0-2)^2 + (3-1)^2 = 8. + let value = problem.evaluate(&[1, 1]); + assert!(matches!(value, Min(Some(v)) if (v - 8.0).abs() < EPS)); +} + +#[test] +fn test_minimum_discrete_planar_inverse_kinematics_evaluate_infeasible() { + let problem = sample_problem(); + + // [1, 0] is not in allowed_pairs[0] = {(0,0),(0,1),(1,1)} -> infeasible. + assert_eq!(problem.evaluate(&[1, 0]), Min(None)); + assert!(!problem.is_valid_solution(&[1, 0])); + assert_eq!(problem.squared_distance(&[1, 0]), None); + assert_eq!(problem.end_effector(&[1, 0]), None); + + // Wrong length: too short. + assert_eq!(problem.evaluate(&[0]), Min(None)); + assert!(!problem.is_valid_solution(&[0])); + + // Wrong length: too long. + assert_eq!(problem.evaluate(&[0, 1, 0]), Min(None)); + + // Index out of range for a per-link domain. + assert_eq!(problem.evaluate(&[0, 2]), Min(None)); +} + +#[test] +fn test_minimum_discrete_planar_inverse_kinematics_solver_finds_optimum() { + let problem = sample_problem(); + let solver = BruteForce::new(); + let witness = solver.find_witness(&problem).unwrap(); + assert!(problem.is_valid_solution(&witness)); + let optimum = problem.squared_distance(&witness).unwrap(); + assert!(optimum.abs() < EPS, "expected optimum 0, got {optimum}"); + + let value = solver.solve(&problem); + assert!(matches!(value, Min(Some(v)) if v.abs() < EPS)); +} + +#[test] +fn test_minimum_discrete_planar_inverse_kinematics_paper_example() { + let problem = sample_problem(); + let config = vec![0, 1]; + let value = problem.evaluate(&config); + assert!(matches!(value, Min(Some(v)) if v.abs() < EPS)); + let (x, y) = problem.end_effector(&config).unwrap(); + assert!((x - 2.0).abs() < EPS); + assert!((y - 1.0).abs() < EPS); +} + +#[test] +fn test_minimum_discrete_planar_inverse_kinematics_serialization() { + let problem = sample_problem(); + let json = serde_json::to_value(&problem).unwrap(); + let restored: MinimumDiscretePlanarInverseKinematics = serde_json::from_value(json).unwrap(); + assert_eq!(restored.link_lengths(), problem.link_lengths()); + assert_eq!(restored.target_point(), problem.target_point()); + assert_eq!( + restored.orientation_samples(), + problem.orientation_samples() + ); + assert_eq!(restored.allowed_pairs(), problem.allowed_pairs()); + assert_eq!(restored.dims(), problem.dims()); +} + +#[test] +fn test_minimum_discrete_planar_inverse_kinematics_problem_name() { + assert_eq!( + ::NAME, + "MinimumDiscretePlanarInverseKinematics" + ); +} From 9383f03a623f31387b141d21da748eb1adaedabd Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 25 May 2026 17:46:46 +0800 Subject: [PATCH 03/50] Fix #994 model: complexity reflects total config product, not 2^n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brute-force search space is prod_{j=1}^n m_j, not 2^n — per-link sample counts m_j are arbitrary. Add a `total_configurations()` getter that returns the product, and rewrite the declare_variants! complexity as `num_links * total_configurations` (n vertices in evaluate cost times the iteration space). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minimum_discrete_planar_inverse_kinematics.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs b/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs index e0f6797e2..1ece357ca 100644 --- a/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs +++ b/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs @@ -165,6 +165,15 @@ impl MinimumDiscretePlanarInverseKinematics { self.link_lengths.len() } + /// Total number of configurations (product of per-link sample counts): + /// `prod_{j=1}^n m_j`. This is the size of the brute-force search space. + pub fn total_configurations(&self) -> usize { + self.orientation_samples + .iter() + .map(|s| s.len()) + .product() + } + /// Check if a configuration is feasible (one index per link, in range, /// and every consecutive pair lies in the corresponding admissible set). pub fn is_feasible(&self, config: &[usize]) -> bool { @@ -241,7 +250,7 @@ impl Problem for MinimumDiscretePlanarInverseKinematics { } crate::declare_variants! { - default MinimumDiscretePlanarInverseKinematics => "2^num_links", + default MinimumDiscretePlanarInverseKinematics => "num_links * total_configurations", } #[cfg(feature = "example-db")] From b0ed7e186282b43e048d9329de3e4b22bf31b260 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 25 May 2026 18:16:47 +0800 Subject: [PATCH 04/50] Reformat total_configurations getter (cargo fmt) Trivial single-line rewrite to match rustfmt. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../misc/minimum_discrete_planar_inverse_kinematics.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs b/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs index 1ece357ca..af637410a 100644 --- a/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs +++ b/src/models/misc/minimum_discrete_planar_inverse_kinematics.rs @@ -168,10 +168,7 @@ impl MinimumDiscretePlanarInverseKinematics { /// Total number of configurations (product of per-link sample counts): /// `prod_{j=1}^n m_j`. This is the size of the brute-force search space. pub fn total_configurations(&self) -> usize { - self.orientation_samples - .iter() - .map(|s| s.len()) - .product() + self.orientation_samples.iter().map(|s| s.len()).product() } /// Check if a configuration is feasible (one index per link, in range, From 910dc66105d13a3d6cbb08a5d1ac98d36977a1ac Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 25 May 2026 18:17:16 +0800 Subject: [PATCH 05/50] Add MaximumCoKPlex model (#1015) Co-k-plex problem: given graph G=(V,E), vertex weights w, and integer k>=1, find max-weight subset S subseteq V such that the induced subgraph G[S] has maximum degree at most k-1 (i.e. every selected vertex has at most k-1 selected neighbours). Generalizes MaximumIndependentSet (the k=1 case) and is the complement-graph view of maximum k-plex from the clique-relaxation literature. - src/models/graph/maximum_co_k_plex.rs: MaximumCoKPlex parameterized by graph type, weight type, and K-multiplier. Only the KN (runtime-k) variant registered initially per the issue's "initially KN, K1/K2/... later" plan. Max objective, induced-degree feasibility, declare_variants! default + i32 variant, canonical example via inventory (5-cycle weights (5,1,4,1,3) k=2, optimum {0,2,4} value 12). - src/unit_tests/models/graph/maximum_co_k_plex.rs: creation, evaluate-feasible (issue optimum + smaller feasible), evaluate- infeasible (degree-2 violation), brute-force solver, serialization. - problemreductions-cli/src/commands/create/: schema-driven CLI maps schema field bound_k to existing --k flag with semantic validation. - docs/paper: problem-def block with C_5 worked example and k=1 -> MaximumIndependentSet equivalence note; references.bib gains Hernandez2016MolecularSimilarity and HosseinianButenko2022KDependent. References: arXiv:1601.06693 (Hernandez et al., 2016) for the molecular-similarity framing; doi:10.1016/j.dam.2021.10.015 (Hosseinian & Butenko, 2022) for the maximum k-dependent set view. Closes #1015 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/paper/reductions.typ | 39 +++ docs/paper/references.bib | 19 ++ .../src/commands/create/schema_semantics.rs | 19 ++ .../src/commands/create/schema_support.rs | 1 + src/models/graph/maximum_co_k_plex.rs | 247 ++++++++++++++++++ src/models/graph/mod.rs | 4 + .../models/graph/maximum_co_k_plex.rs | 117 +++++++++ 7 files changed, 446 insertions(+) create mode 100644 src/models/graph/maximum_co_k_plex.rs create mode 100644 src/unit_tests/models/graph/maximum_co_k_plex.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 353ab77e9..d65df58b3 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -204,6 +204,7 @@ "BottleneckTravelingSalesman": [Bottleneck Traveling Salesman], "TravelingSalesman": [Traveling Salesman], "MaximumClique": [Maximum Clique], + "MaximumCoKPlex": [Maximum Co-$k$-Plex], "MaximumSetPacking": [Maximum Set Packing], "MinimumHittingSet": [Minimum Hitting Set], "MinimumSetCovering": [Minimum Set Covering], @@ -780,6 +781,44 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| ] } +#{ + let x = load-model-example("MaximumCoKPlex") + let nv = graph-num-vertices(x.instance) + let ne = graph-num-edges(x.instance) + let edges = x.instance.graph.edges + let weights = x.instance.weights + let k = x.instance.bound_k + let sol = (config: x.optimal_config, metric: x.optimal_value) + let S = sol.config.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i) + let wS = metric-value(sol.metric) + [ + #problem-def("MaximumCoKPlex")[ + Given $G = (V, E)$ with vertex weights $w: V -> RR$ and an integer $k >= 1$, find $S subset.eq V$ maximizing $sum_(v in S) w(v)$ such that the induced subgraph $G[S]$ has maximum degree at most $k - 1$: $forall v in S, deg_(G[S])(v) <= k - 1$. + ][ + The Maximum Co-$k$-Plex (also called the maximum $(k - 1)$-dependent set) is a clique-relaxation model that interpolates between the Maximum Independent Set ($k = 1$) and bounded-conflict variants used in molecular similarity scoring @Hernandez2016MolecularSimilarity and bipartite-side combinatorial optimization @HosseinianButenko2022KDependent. Its complement view is the maximum $k$-plex on $overline(G)$. The brute-force baseline enumerates all $2^n$ subsets in $O^*(2^n)$ time#footnote[No algorithm improving on brute-force enumeration is currently registered for the default `KN` variant.]. + + *Example.* Consider the 5-cycle $C_5$ with $n = #nv$ vertices, $|E| = #ne$ edges #edges.map(((u, v)) => [${#u, #v}$]).join(", "), vertex weights $w = #(weights)$, and $k = #k$. The set $S = {#S.map(i => $v_#i$).join(", ")}$ has weight $w(S) = #wS$. Its induced subgraph contains only the chord $(v_4, v_0)$, so the induced-degree sequence on $S$ is $(1, 0, 1)$ -- every selected vertex satisfies $deg_(G[S])(v) <= k - 1 = 1$. + + #pred-commands( + "pred create --example " + problem-spec(x) + " -o co-k-plex.json", + "pred solve co-k-plex.json", + "pred evaluate co-k-plex.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure({ + let r = 1.1 + let verts = range(nv).map(i => { + let angle = calc.pi / 2 + 2 * calc.pi * i / nv + (r * calc.cos(angle), r * calc.sin(angle)) + }) + draw-node-highlight(verts, edges, S) + }, + caption: [The 5-cycle $C_5$ with $w = #(weights)$ and $k = #k$. Selected vertices $S = {#S.map(i => $v_#i$).join(", ")}$ (blue) have total weight $#wS$; in $G[S]$ every vertex has induced degree at most $k - 1 = 1$.], + ) + ] + ] +} + #{ let x = load-model-example("MinimumVertexCover") let nv = graph-num-vertices(x.instance) diff --git a/docs/paper/references.bib b/docs/paper/references.bib index a9eb68d98..8e273e82c 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -1867,3 +1867,22 @@ @article{daiizatttedrake2019 year = {2019}, doi = {10.1177/0278364919846512} } + + +@article{Hernandez2016MolecularSimilarity, + author = {Maritza Hernandez and Arman Zaribafiyan and Maliheh Aramon and Mohammad Naghibi}, + title = {A Novel Graph-based Approach for Determining Molecular Similarity}, + journal = {arXiv preprint arXiv:1601.06693}, + year = {2016}, + url = {https://arxiv.org/abs/1601.06693} +} + +@article{HosseinianButenko2022KDependent, + author = {Seyedmohammadhossein Hosseinian and Sergiy Butenko}, + title = {An improved approximation for Maximum k-dependent Set on bipartite graphs}, + journal = {Discrete Applied Mathematics}, + volume = {307}, + pages = {95--101}, + year = {2022}, + doi = {10.1016/j.dam.2021.10.015} +} diff --git a/problemreductions-cli/src/commands/create/schema_semantics.rs b/problemreductions-cli/src/commands/create/schema_semantics.rs index 3756dbd00..c07ee4d35 100644 --- a/problemreductions-cli/src/commands/create/schema_semantics.rs +++ b/problemreductions-cli/src/commands/create/schema_semantics.rs @@ -713,6 +713,25 @@ pub(super) fn validate_schema_driven_semantics( &weights, )?; } + "MaximumCoKPlex" => { + let usage = "Usage: pred create MaximumCoKPlex/i32 --graph 0-1,1-2,2-3,3-4,4-0 --weights 5,1,4,1,3 --k 2"; + let (_, num_vertices) = + parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let weights = parse_vertex_weights(args, num_vertices)?; + let graph_type = resolved_graph_type(resolved_variant); + reject_nonunit_weights_for_one_variant( + canonical, + graph_type, + resolved_variant, + &weights, + )?; + let k = args + .k + .ok_or_else(|| anyhow::anyhow!("MaximumCoKPlex requires --k\n\n{usage}"))?; + if k == 0 { + bail!("MaximumCoKPlex: --k must be at least 1\n\n{usage}"); + } + } "MinimumHittingSet" => { let universe = args.universe.ok_or_else(|| { anyhow::anyhow!( diff --git a/problemreductions-cli/src/commands/create/schema_support.rs b/problemreductions-cli/src/commands/create/schema_support.rs index b3dee9fe8..704b9121a 100644 --- a/problemreductions-cli/src/commands/create/schema_support.rs +++ b/problemreductions-cli/src/commands/create/schema_support.rs @@ -1937,6 +1937,7 @@ pub(super) fn help_flag_name(canonical: &str, field_name: &str) -> String { ("StackerCrane", "arc_lengths") => return "arc-lengths".to_string(), ("StackerCrane", "edge_lengths") => return "edge-lengths".to_string(), ("StaffScheduling", "shifts_per_schedule") => return "k".to_string(), + ("MaximumCoKPlex", "bound_k") => return "k".to_string(), ("TimetableDesign", "num_tasks") => return "num-tasks".to_string(), _ => {} } diff --git a/src/models/graph/maximum_co_k_plex.rs b/src/models/graph/maximum_co_k_plex.rs new file mode 100644 index 000000000..fd038d967 --- /dev/null +++ b/src/models/graph/maximum_co_k_plex.rs @@ -0,0 +1,247 @@ +//! Maximum Co-k-Plex problem implementation. +//! +//! Given an undirected graph G = (V, E), vertex weights w: V -> R, and an +//! integer k >= 1, find a subset S ⊆ V maximizing Σ_{v ∈ S} w(v) such that +//! the induced subgraph G[S] has maximum degree at most k - 1. Equivalently, +//! every selected vertex has at most k - 1 selected neighbours. +//! +//! For k = 1 the problem degenerates to [`MaximumIndependentSet`]; for larger +//! k it is the maximum (k-1)-dependent set / co-k-plex. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::Problem; +use crate::types::{Max, One, WeightElement}; +use crate::variant::{KValue, VariantParam, KN}; +use num_traits::Zero; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "MaximumCoKPlex", + display_name: "Maximum Co-k-Plex", + aliases: &[], + dimensions: &[ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + VariantDimension::new("weight", "One", &["One", "i32"]), + VariantDimension::new("k", "KN", &["KN"]), + ], + module_path: module_path!(), + description: "Find maximum-weight vertex subset whose induced subgraph has maximum degree at most k-1", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "weights", type_name: "Vec", description: "Vertex weights w: V -> R" }, + FieldInfo { name: "bound_k", type_name: "usize", description: "Co-k-plex parameter k >= 1; selected-vertex induced degree must be at most k-1" }, + ], + } +} + +inventory::submit! { + ProblemSizeFieldEntry { + name: "MaximumCoKPlex", + fields: &["num_vertices", "num_edges"], + } +} + +/// The Maximum Co-k-Plex problem. +/// +/// Given a graph `G = (V, E)`, vertex weights `w_v`, and an integer +/// `k >= 1`, find `S ⊆ V` maximizing `Σ_{v ∈ S} w_v` subject to +/// `deg_{G[S]}(v) <= k - 1` for every `v ∈ S` (equivalently, the induced +/// subgraph has maximum degree at most `k - 1`). +/// +/// # Type Parameters +/// +/// * `G` - Graph type (e.g., [`SimpleGraph`]). +/// * `W` - Weight type (e.g., [`One`], `i32`). +/// * `K` - Compile-time [`KValue`] tag. [`KN`] stores `k` at runtime; fixed +/// variants (`K1`, `K2`, ...) can be added later by registering more +/// `declare_variants!` entries. +/// +/// # Example +/// +/// ``` +/// use problemreductions::models::graph::MaximumCoKPlex; +/// use problemreductions::topology::SimpleGraph; +/// use problemreductions::types::One; +/// use problemreductions::variant::KN; +/// use problemreductions::{BruteForce, Problem, Solver}; +/// +/// // 5-cycle C_5 with k = 2 (induced degree <= 1). +/// let graph = SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]); +/// let problem = +/// MaximumCoKPlex::<_, One, KN>::with_k(graph, vec![One; 5], 2); +/// assert_eq!(problem.bound_k(), 2); +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(bound(deserialize = "G: serde::Deserialize<'de>, W: serde::Deserialize<'de>"))] +pub struct MaximumCoKPlex { + /// The underlying graph. + graph: G, + /// Per-vertex weights `w_v`. + weights: Vec, + /// Runtime co-k-plex parameter `k`. For compile-time `K` it equals `K::K`. + #[serde(default = "default_bound_k::")] + bound_k: usize, + #[serde(skip)] + _phantom: std::marker::PhantomData, +} + +fn default_bound_k() -> usize { + K::K.unwrap_or(0) +} + +impl MaximumCoKPlex { + /// Create an instance with an explicit runtime `k`. + /// + /// # Panics + /// Panics if `weights.len()` does not match `graph.num_vertices()`, if + /// `bound_k == 0`, or if `K` declares a fixed value that disagrees with + /// `bound_k`. + pub fn with_k(graph: G, weights: Vec, bound_k: usize) -> Self { + assert_eq!( + weights.len(), + graph.num_vertices(), + "weights length must match graph num_vertices" + ); + assert!(bound_k >= 1, "co-k-plex parameter k must be at least 1"); + if let Some(fixed) = K::K { + assert_eq!( + fixed, bound_k, + "fixed K type disagrees with runtime bound_k" + ); + } + Self { + graph, + weights, + bound_k, + _phantom: std::marker::PhantomData, + } + } + + /// Create a new instance using the compile-time `K`. + /// + /// # Panics + /// Panics if `K` is [`KN`] (use [`MaximumCoKPlex::with_k`] instead) or if + /// `weights.len()` does not match `graph.num_vertices()`. + pub fn new(graph: G, weights: Vec) -> Self { + let k = K::K.expect("KN requires with_k"); + Self::with_k(graph, weights, k) + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get a reference to the vertex weights. + pub fn weights(&self) -> &[W] { + &self.weights + } + + /// Co-k-plex parameter `k`. + pub fn bound_k(&self) -> usize { + self.bound_k + } + + /// Check if the problem uses a non-unit weight type. + pub fn is_weighted(&self) -> bool + where + W: WeightElement, + { + !W::IS_UNIT + } + + /// Check if a configuration satisfies the co-k-plex constraint. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + is_co_k_plex_config(&self.graph, config, self.bound_k) + } +} + +impl MaximumCoKPlex { + /// Number of vertices in the underlying graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Number of edges in the underlying graph. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } +} + +impl Problem for MaximumCoKPlex +where + G: Graph + VariantParam, + W: WeightElement + VariantParam, + K: KValue, +{ + const NAME: &'static str = "MaximumCoKPlex"; + type Value = Max; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G, W, K] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_vertices()] + } + + fn evaluate(&self, config: &[usize]) -> Max { + if !is_co_k_plex_config(&self.graph, config, self.bound_k) { + return Max(None); + } + let mut total = W::Sum::zero(); + for (i, &selected) in config.iter().enumerate() { + if selected == 1 { + total += self.weights[i].to_sum(); + } + } + Max(Some(total)) + } +} + +/// Return true iff every selected vertex has at most `k - 1` selected +/// neighbours in the induced subgraph. +fn is_co_k_plex_config(graph: &G, config: &[usize], bound_k: usize) -> bool { + if bound_k == 0 { + return false; + } + let n = graph.num_vertices(); + let mut induced_degree = vec![0usize; n]; + for (u, v) in graph.edges() { + let u_selected = config.get(u).copied().unwrap_or(0) == 1; + let v_selected = config.get(v).copied().unwrap_or(0) == 1; + if u_selected && v_selected { + induced_degree[u] += 1; + induced_degree[v] += 1; + if induced_degree[u] > bound_k - 1 || induced_degree[v] > bound_k - 1 { + return false; + } + } + } + true +} + +crate::declare_variants! { + default MaximumCoKPlex => "2^num_vertices", + MaximumCoKPlex => "2^num_vertices", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "maximum_co_k_plex_simplegraph_i32", + instance: Box::new(MaximumCoKPlex::<_, i32, KN>::with_k( + SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]), + vec![5, 1, 4, 1, 3], + 2, + )), + optimal_config: vec![1, 0, 1, 0, 1], + optimal_value: serde_json::json!(12), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/maximum_co_k_plex.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index cb7ad098c..8e4a05e9b 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -19,6 +19,7 @@ //! - [`MinimumGeometricConnectedDominatingSet`]: Minimum connected dominating set in a geometric point set //! - [`MinimumFeedbackVertexSet`]: Minimum weight feedback vertex set in a directed graph //! - [`MaximumClique`]: Maximum weight clique +//! - [`MaximumCoKPlex`]: Maximum-weight vertex subset with induced degree at most k-1 //! - [`MaximumAchromaticNumber`]: Maximum number of colors in a complete proper coloring //! - [`MaximumDomaticNumber`]: Maximum partition into disjoint dominating sets //! - [`MaxCut`]: Maximum cut on weighted graphs @@ -105,6 +106,7 @@ pub(crate) mod max_cut; pub(crate) mod maximal_is; pub(crate) mod maximum_achromatic_number; pub(crate) mod maximum_clique; +pub(crate) mod maximum_co_k_plex; pub(crate) mod maximum_domatic_number; pub(crate) mod maximum_independent_set; pub(crate) mod maximum_leaf_spanning_tree; @@ -180,6 +182,7 @@ pub use max_cut::MaxCut; pub use maximal_is::MaximalIS; pub use maximum_achromatic_number::MaximumAchromaticNumber; pub use maximum_clique::MaximumClique; +pub use maximum_co_k_plex::MaximumCoKPlex; pub use maximum_domatic_number::MaximumDomaticNumber; pub use maximum_independent_set::MaximumIndependentSet; pub use maximum_leaf_spanning_tree::MaximumLeafSpanningTree; @@ -263,6 +266,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec SimpleGraph { + SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]) +} + +fn issue_instance() -> MaximumCoKPlex { + MaximumCoKPlex::<_, i32, KN>::with_k(c5(), vec![5, 1, 4, 1, 3], 2) +} + +#[test] +fn test_maximum_co_k_plex_creation() { + let problem = issue_instance(); + assert_eq!(problem.graph().num_vertices(), 5); + assert_eq!(problem.graph().num_edges(), 5); + assert_eq!(problem.weights(), &[5, 1, 4, 1, 3]); + assert_eq!(problem.bound_k(), 2); + assert_eq!(problem.dims(), vec![2; 5]); + assert_eq!(problem.num_vertices(), 5); + assert_eq!(problem.num_edges(), 5); + assert!(problem.is_weighted()); +} + +#[test] +fn test_maximum_co_k_plex_evaluate_feasible() { + let problem = issue_instance(); + + // Optimum from the issue: x = (1,0,1,0,1), S = {0,2,4}. + // Induced subgraph has only the edge (4,0); induced degrees (1,0,1) all <= 1. + assert_eq!(problem.evaluate(&[1, 0, 1, 0, 1]), Max(Some(5 + 4 + 3))); + + // S = {0,1}: induced edge (0,1), induced degrees (1,1) -- still feasible at k=2. + assert_eq!(problem.evaluate(&[1, 1, 0, 0, 0]), Max(Some(5 + 1))); + + // Empty set: always feasible. + assert_eq!(problem.evaluate(&[0; 5]), Max(Some(0))); +} + +#[test] +fn test_maximum_co_k_plex_evaluate_infeasible() { + let problem = issue_instance(); + + // S = {0,1,2}: vertex 1 has induced degree 2 > k-1 = 1. + assert_eq!(problem.evaluate(&[1, 1, 1, 0, 0]), Max(None)); + assert!(!problem.is_valid_solution(&[1, 1, 1, 0, 0])); + + // Whole 5-cycle: every vertex has induced degree 2 > 1. + assert_eq!(problem.evaluate(&[1; 5]), Max(None)); +} + +#[test] +fn test_maximum_co_k_plex_brute_force() { + let problem = issue_instance(); + let solver = BruteForce::new(); + let aggregate = solver.solve(&problem); + assert_eq!(aggregate, Max(Some(12))); + + let witness = solver.find_witness(&problem).expect("witness exists"); + assert!(problem.is_valid_solution(&witness)); + assert_eq!(problem.evaluate(&witness), Max(Some(12))); +} + +#[test] +fn test_maximum_co_k_plex_k_equals_1_is_independent_set() { + // For k = 1 the co-k-plex constraint forces an independent set. + // 5-cycle MIS has size 2, so unit-weight optimum is 2. + let problem = MaximumCoKPlex::<_, One, KN>::with_k(c5(), vec![One; 5], 1); + let solver = BruteForce::new(); + assert_eq!(solver.solve(&problem), Max(Some(2))); + + // Picking adjacent vertices violates the k=1 constraint. + assert_eq!(problem.evaluate(&[1, 1, 0, 0, 0]), Max(None)); + // Any two non-adjacent vertices is feasible. + assert_eq!(problem.evaluate(&[1, 0, 1, 0, 0]), Max(Some(2))); +} + +#[test] +fn test_maximum_co_k_plex_serialization_roundtrip() { + let problem = issue_instance(); + let json = serde_json::to_value(&problem).expect("serialize"); + let restored: MaximumCoKPlex = + serde_json::from_value(json).expect("deserialize"); + assert_eq!(restored.graph().num_vertices(), 5); + assert_eq!(restored.weights(), &[5, 1, 4, 1, 3]); + assert_eq!(restored.bound_k(), 2); + assert_eq!(restored.evaluate(&[1, 0, 1, 0, 1]), Max(Some(12))); +} + +#[test] +fn test_maximum_co_k_plex_problem_name_and_variant() { + assert_eq!( + as Problem>::NAME, + "MaximumCoKPlex" + ); + let v = as Problem>::variant(); + assert!(v.contains(&("graph", "SimpleGraph"))); + assert!(v.contains(&("weight", "One"))); + assert!(v.contains(&("k", "KN"))); +} + +#[test] +#[should_panic(expected = "co-k-plex parameter k must be at least 1")] +fn test_maximum_co_k_plex_rejects_zero_k() { + let _ = MaximumCoKPlex::<_, One, KN>::with_k(c5(), vec![One; 5], 0); +} + +#[test] +#[should_panic(expected = "weights length must match graph num_vertices")] +fn test_maximum_co_k_plex_rejects_weight_length_mismatch() { + let _ = MaximumCoKPlex::<_, One, KN>::with_k(c5(), vec![One; 4], 2); +} From 2d6b58426dcd7e26906294abd771b5df2b70860b Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 25 May 2026 18:45:53 +0800 Subject: [PATCH 06/50] Add MaximumCommonEdgeSubgraph model (#1018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCES: given two directed edge-labelled graphs G1, G2, find a partial injective map f: U1 ⊆ V1 → V2 maximizing the number of preserved labelled arcs (u, λ, v) ∈ E1 with f(u), f(v) defined and (f(u), λ, f(v)) ∈ E2. Edge labels must match exactly; set semantics (no multiplicities); disconnected common subgraphs allowed; no secondary tie-break. - src/models/graph/maximum_common_edge_subgraph.rs: local LabelledArc + LabelledDigraph structs (does not extend the existing Graph trait hierarchy in this PR). dims = vec![|V2|+1; |V1|] with the +1 slot encoding ⊥. Max objective with injectivity feasibility on the matched slots. ProblemSchemaEntry + ProblemSizeFieldEntry for num_vertices_1/_2 and num_arcs_1/_2, declare_variants! default with complexity (num_vertices_2+1)^num_vertices_1. Canonical example via inventory from the issue's 5-vs-4-vertex instance with optimum value 5. - src/unit_tests/models/graph/maximum_common_edge_subgraph.rs: 12 tests covering creation, evaluate-feasible (optimum 5), evaluate-injectivity-violated, evaluate-fewer-preserved, brute-force solver, serialization. - problemreductions-cli/: new --graph-1 / --graph-2 flags with a LabelledDigraph parser; alias MCES. - docs/paper: problem-def block, display-name, MCES worked example. - docs/paper/references.bib: corrected per Crossref against the check-issue warning — Bahiense2012 first names (Laura/Gordana/Breno), Soule2021 author list (Soule/Reinharz/Sarrazin-Gendron/Denise/ Waldispuhl) and venue, Bokhari1981 volume (C-30). References: doi:10.1109/TC.1981.1675756 (Bokhari 1981), doi:10.1016/j.dam.2012.01.026 (Bahiense et al. 2012, polyhedral investigation), doi:10.1371/journal.pcbi.1008990 (Soule et al. 2021, RNA networks application). The direct `MaximumCommonEdgeSubgraph -> ILP` rule (#1019) is out of scope for this PR and will follow separately. Closes #1018 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/paper/reductions.typ | 116 +++++++ docs/paper/references.bib | 33 ++ problemreductions-cli/src/cli.rs | 8 + problemreductions-cli/src/commands/create.rs | 8 +- .../src/commands/create/schema_support.rs | 75 +++++ .../src/commands/create/tests.rs | 2 + .../graph/maximum_common_edge_subgraph.rs | 316 ++++++++++++++++++ src/models/graph/mod.rs | 4 + .../graph/maximum_common_edge_subgraph.rs | 176 ++++++++++ 9 files changed, 735 insertions(+), 3 deletions(-) create mode 100644 src/models/graph/maximum_common_edge_subgraph.rs create mode 100644 src/unit_tests/models/graph/maximum_common_edge_subgraph.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index d65df58b3..eaef98ab1 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -205,6 +205,7 @@ "TravelingSalesman": [Traveling Salesman], "MaximumClique": [Maximum Clique], "MaximumCoKPlex": [Maximum Co-$k$-Plex], + "MaximumCommonEdgeSubgraph": [Maximum Common Edge Subgraph], "MaximumSetPacking": [Maximum Set Packing], "MinimumHittingSet": [Minimum Hitting Set], "MinimumSetCovering": [Minimum Set Covering], @@ -819,6 +820,121 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| ] } +#{ + // Hand-authored canonical example mirroring the in-repo example_db fixture + // (load-model-example is not used here because the corresponding example + // entry is shipped via the model file's canonical_model_example_specs rather + // than the docs/paper/data/examples.json bundle). + let g1 = ( + num_vertices: 5, + arcs: ( + (src: 0, label: 0, dst: 1), + (src: 1, label: 1, dst: 2), + (src: 0, label: 2, dst: 2), + (src: 2, label: 0, dst: 3), + (src: 1, label: 3, dst: 3), + (src: 3, label: 1, dst: 4), + ), + ) + let g2 = ( + num_vertices: 4, + arcs: ( + (src: 0, label: 0, dst: 1), + (src: 1, label: 1, dst: 2), + (src: 0, label: 2, dst: 2), + (src: 2, label: 0, dst: 3), + (src: 1, label: 3, dst: 3), + (src: 0, label: 1, dst: 3), + ), + ) + let n1 = g1.num_vertices + let n2 = g2.num_vertices + let label-name = ("a", "b", "c", "d") + let label-str(l) = label-name.at(l, default: str(l)) + let fmt-arcs(arcs) = arcs.map(a => $(#a.src, #label-str(a.label), #a.dst)$).join(", ") + let f = (0, 1, 2, 3, 4) // 4 encodes ⊥ since |V2| = 4 + let preserved = 5 + [ + #problem-def("MaximumCommonEdgeSubgraph")[ + Given two finite directed edge-labelled graphs $G_1 = (V_1, E_1)$ and $G_2 = (V_2, E_2)$ with $E_i subset.eq V_i times Sigma times V_i$, find a partial injective map $f: U_1 -> V_2$, where $U_1 subset.eq V_1$, maximizing the number of preserved labelled arcs + $ |{(u, lambda, v) in E_1 : u, v in U_1 "and" (f(u), lambda, f(v)) in E_2}|. $ + Edge labels must match exactly, vertex labels are ignored, and the model uses set semantics: each preserved labelled arc contributes $1$, independent of multiplicity. + ][ + The Maximum Common Edge Subgraph problem (MCES) was introduced by Bokhari as a model for the task-assignment / mapping problem on parallel architectures @Bokhari1981Mapping. Bahiense, Mani{\'c}, Piva, and de Souza later gave a thorough polyhedral investigation and exact branch-and-cut algorithms for general undirected MCES @Bahiense2012MCES. Soul{\'e}, Reinharz, Sarrazin-Gendron, Denise, and Waldisp{\"u}hl use a maximal (not maximum) common subgraph enumeration over edge-coloured graphs to detect recurrent RNA structural networks @Soule2021RNA; the edge-maximizing optimization surrogate registered here is the natural objective version of their setting. The decision form is NP-complete by direct reduction from Subgraph Isomorphism. The registered exact baseline enumerates every assignment $V_1 -> V_2 union {bot}$ in $O^*((|V_2| + 1)^(|V_1|))$ time and filters to injective maps#footnote[No algorithm improving on full enumeration is registered for the unlabelled-vertex variant. Refinements such as branch-and-bound on a product graph @Bahiense2012MCES improve on the worst case in practice but not in worst-case complexity.]. + + // Pretty-print the partial map: render f(u) as ⊥ when u is unmatched. + #let render-target(v) = if v == n2 { $bot$ } else { $#v$ } + #let map-tuple = f.map(v => render-target(v)).join($, $) + + *Example.* Encode the alphabet $Sigma = {a, b, c, d}$ as ${0, 1, 2, 3}$ (alphabetical). Let + $V_1 = {0, 1, 2, 3, 4}$ with $E_1 = {#fmt-arcs(g1.arcs)}$ and + $V_2 = {0, 1, 2, 3}$ with $E_2 = {#fmt-arcs(g2.arcs)}$. + The partial injective map $f = (#map-tuple)$ preserves $#preserved$ of the $#g1.arcs.len()$ source arcs; the only unmatched source arc is $(3, b, 4)$, since vertex $4 in V_1$ is left unmatched. No injective map can preserve all $#g1.arcs.len()$ source arcs, because matching every vertex of $V_1$ injectively into $V_2$ would require $|V_2| >= |V_1| = #n1 > #n2$. + + #pred-commands( + "pred create --example MaximumCommonEdgeSubgraph -o mces.json", + "pred solve mces.json --solver brute-force", + "pred evaluate mces.json --config " + f.map(str).join(","), + ) + + #figure({ + let r1 = 1.2 + let r2 = 1.0 + let dx = 4.0 + let verts1 = range(n1).map(i => { + let angle = calc.pi / 2 - 2 * calc.pi * i / n1 + (r1 * calc.cos(angle), r1 * calc.sin(angle)) + }) + let verts2 = range(n2).map(i => { + let angle = calc.pi / 2 - 2 * calc.pi * i / n2 + (dx + r2 * calc.cos(angle), r2 * calc.sin(angle)) + }) + canvas(length: 1cm, { + import draw: * + // G1 arcs + for arc in g1.arcs { + let p = verts1.at(arc.src) + let q = verts1.at(arc.dst) + let mid = ((p.at(0) + q.at(0)) / 2, (p.at(1) + q.at(1)) / 2) + line(p, q, mark: (end: "straight"), stroke: 0.7pt + luma(120)) + content(mid, text(7pt)[#label-str(arc.label)], frame: "rect", fill: white, stroke: none, padding: 0.04) + } + for (k, pos) in verts1.enumerate() { + let matched = f.at(k) != n2 + g-node(pos, name: "u" + str(k), + fill: if matched { graph-colors.at(0) } else { white }, + label: if matched { text(fill: white)[$#k$] } else { [$#k$] }) + } + // G2 arcs + for arc in g2.arcs { + let p = verts2.at(arc.src) + let q = verts2.at(arc.dst) + let mid = ((p.at(0) + q.at(0)) / 2, (p.at(1) + q.at(1)) / 2) + line(p, q, mark: (end: "straight"), stroke: 0.7pt + luma(120)) + content(mid, text(7pt)[#label-str(arc.label)], frame: "rect", fill: white, stroke: none, padding: 0.04) + } + for (k, pos) in verts2.enumerate() { + g-node(pos, name: "v" + str(k), fill: white, label: [$#k$]) + } + // Mapping arrows + for u in range(n1) { + let v = f.at(u) + if v != n2 { + line(verts1.at(u), verts2.at(v), + stroke: (paint: graph-colors.at(0), thickness: 0.6pt, dash: "dashed"), + mark: (end: "straight")) + } + } + content((verts1.at(0).at(0) - 0.6, r1 + 0.5), text(9pt, weight: "bold")[$G_1$]) + content((verts2.at(0).at(0) - 0.6, r2 + 0.5), text(9pt, weight: "bold")[$G_2$]) + }) + }, + caption: [Maximum Common Edge Subgraph instance from the issue. Left: source graph $G_1$ with $|V_1| = #n1$ and $|E_1| = #g1.arcs.len()$ labelled arcs; matched source vertices are highlighted. Right: target graph $G_2$ with $|V_2| = #n2$ and $|E_2| = #g2.arcs.len()$. Dashed arrows show the partial injective map $f$; the source arc $(3, b, 4)$ is the unique non-preserved arc because vertex $4 in V_1$ is unmatched.], + ) + ] + ] +} + #{ let x = load-model-example("MinimumVertexCover") let nv = graph-num-vertices(x.instance) diff --git a/docs/paper/references.bib b/docs/paper/references.bib index 8e273e82c..3a2dc58eb 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -1886,3 +1886,36 @@ @article{HosseinianButenko2022KDependent year = {2022}, doi = {10.1016/j.dam.2021.10.015} } + +@article{Bokhari1981Mapping, + author = {Shahid H. Bokhari}, + title = {On the Mapping Problem}, + journal = {IEEE Transactions on Computers}, + volume = {C-30}, + number = {3}, + pages = {207--214}, + year = {1981}, + doi = {10.1109/TC.1981.1675756} +} + +@article{Bahiense2012MCES, + author = {Laura Bahiense and Gordana Mani{\'c} and Breno Piva and Cid C. de Souza}, + title = {The maximum common edge subgraph problem: A polyhedral investigation}, + journal = {Discrete Applied Mathematics}, + volume = {160}, + number = {18}, + pages = {2523--2541}, + year = {2012}, + doi = {10.1016/j.dam.2012.01.026} +} + +@article{Soule2021RNA, + author = {Antoine Soul{\'e} and Vladimir Reinharz and Roman Sarrazin-Gendron and Alain Denise and J{\'e}r{\^o}me Waldisp{\"u}hl}, + title = {Finding recurrent RNA structural networks with fast maximal common subgraphs of edge-colored graphs}, + journal = {PLOS Computational Biology}, + volume = {17}, + number = {5}, + pages = {e1008990}, + year = {2021}, + doi = {10.1371/journal.pcbi.1008990} +} diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 730117171..f6f352d8c 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -578,6 +578,12 @@ pub struct CreateArgs { /// Admissible (a_{j-1}, a_j) pair sets per junction for MinimumDiscretePlanarInverseKinematics (pipe-separated junctions, each comma-separated "i-j" pairs, e.g., "0-0,0-1,1-1") #[arg(long)] pub allowed_pairs: Option, + /// Source labelled digraph G1 for MaximumCommonEdgeSubgraph. Format: ":,,..." with each arc "-