Skip to content

Recursion through Clingo() emits scrambled iteration frames — cycle evaluates one round regardless of @Recursive; functional predicates in recursive SCCs generally mishandled #547

Description

@ludaesch

Found while preparing experiments for the ASPOCP'26 paper (Skvortsov, Xia, Garg, Bowers, Ludäscher). Logica @ GitHub main (2026-08-06), clingo 5.8.1, DuckDB 1.5.5, macOS; installed via pip install git+https://github.com/EvgSkv/logica in a fresh venv.

Summary

A recursive cycle that passes through a Clingo() call is evaluated for exactly one round — the feedback edge is effectively cut — at every @Recursive budget. Inspecting the generated SQL shows the unroller does attempt to unfold the cycle, but emits a scrambled frame graph: several iteration-frame tables are CREATE TABLE'd twice from different inputs and overwrite each other mid-execution. A no-Clingo control with the same shape (functional predicate inside the SCC) fails outright with a DuckDB binder error, suggesting the root cause is general: recursion through functional (=) predicates in an SCC.

Repro 1 — one round instead of iteration

@Engine("duckdb", clingo: {time_limit: ∞, models_limit: ∞});

# Candidates: "i", and the successor (one more "i") of anything picked.
In(x) distinct :- x == "i" | Out(y), x == y ++ "i";

# ASP fragment: free choice; union over all models = all candidates.
Pick(x) couldbe :- Cand(x);

M() = Clingo(["Pick", "Cand"],
             List{ClingoFact("Cand", [x]) :- In(x)});

Out(x) distinct :-
  ExtractClingoCall(x, predicate: "Pick", model_id:) = M();

logica grow.l run Out{"i"} (one round).

Control without the solver (same growth recursion):

@Engine("duckdb");
In(x) distinct :- x == "i" | In(y), x == y ++ "i", Length(y) < 20;

"i" … "iiiii" (normal iterative evaluation). Adding @Recursive(In, 12) or (24) to the Clingo version adds exactly one _ifr frame to the plan and never changes the result.

Evidence: the generated frame graph is incoherent

logica grow.l print Out emits frames In_ifr0..4, M_ifr0..4, Out_ifr1..4, and the feedback edge is present (In_ifr4 reads Out_ifr3.col0 || 'i') — so this is not a deliberate cut. But mapping each CREATE TABLE to the tables it reads gives three interleaved chains:

In_ifr1 (base) -> M_ifr2 -> Out_ifr3 -> In_ifr4 -> M_ifr3
M_ifr0 (base)  -> Out_ifr1 -> In_ifr2 -> M_ifr3      (M_ifr3 created 2nd time)
In_ifr0 (base) -> M_ifr1 -> Out_ifr2 -> In_ifr3 -> M_ifr4
Out_ifr3 <- M_ifr3, M_ifr4                            (Out_ifr3 created 2nd time)
final SELECT <- Out_ifr3, M_ifr3, M_ifr4

M_ifr3, In_ifr3, and Out_ifr3 are each created twice with different inputs; sequential execution silently overwrites, and the final answer reads from mixed chains.

Repro 2 — same shape without Clingo doesn't even bind

@Engine("duckdb");
In(x) distinct :- x == "i" | Out(y), x == y ++ "i";
F() = List{x :- In(x)};
Out(x) distinct :- x in F(), Length(x) < 6;

_duckdb.BinderException: Binder Error: UNNEST not supported here

So the pure functional-predicate-in-SCC case errors, while the Clingo case produces running-but-wrong SQL.

Suggested fix

Until the unroller supports SCCs containing functional predicates (and Clingo() calls in particular): detect and reject them at compile time with a clear message. Silent one-round evaluation is the worst outcome — the ASPOCP camera-ready now documents the current behavior as "mutual recursion across the boundary is currently not iterated."

Smaller issues found in the same session (can file separately if useful)

  1. Empty List{ClingoFact(...) :- ...} (zero rows) makes the facts argument NULL and Clingo() silently returns NULL — no error, no models.
  2. ~P(x) over fact-fed predicates in a cantbe rule makes the dependency check demand a synthesized internal predicate: "Predicate MustPick depends on IsNull but IsNull was not requested."
  3. Count{...} shouldbe passes the compiler but generates ASP that clingo rejects with "parsing failed" (only Max/Min are documented for optimization).
  4. Docs/paper use @Engine("duckdb", clingo: {time_limit: inf, ...}) but the parser only accepts inf fails with "Could not understand arguments of annotation."

🤖 Generated with Claude Code

https://claude.ai/code/session_01NA7AvfwRmaHjWwcsFgbxo4

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions