asp-studio 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
asp_studio/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """ASP Studio: answer sets, graph and a step-by-step fixpoint animator for Answer Set Programming in Python.
2
+
3
+ import asp_studio as asp
4
+ res = asp.solve("a :- not b. b :- not a.") # clingo Python API under the hood
5
+ asp.show(res) # visualizer in a Jupyter / VS Code notebook
6
+
7
+ In a notebook: ``%load_ext asp_studio`` and start a cell with ``%%asp``.
8
+ """
9
+ from .solver import Diagnostic, Result, solve
10
+ from .render import iframe, page, show
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = ["solve", "show", "Result", "Diagnostic", "page", "iframe", "load_ipython_extension", "__version__"]
14
+
15
+
16
+ def load_ipython_extension(ipython): # `%load_ext asp_studio`
17
+ from .magic import load_ipython_extension as _load
18
+ _load(ipython)
asp_studio/__main__.py ADDED
@@ -0,0 +1,65 @@
1
+ """``python -m asp_studio program.lp`` (or ``asp-studio program.lp``): solve files and write a standalone HTML report."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import pathlib
6
+ import sys
7
+ import webbrowser
8
+ from typing import List, Optional
9
+
10
+ from .render import page
11
+ from .solver import solve
12
+
13
+
14
+ def _fail(message: str, code: int = 2) -> int:
15
+ print(f"error: {message}", file=sys.stderr)
16
+ return code
17
+
18
+
19
+ def main(argv: Optional[List[str]] = None) -> int:
20
+ ap = argparse.ArgumentParser(prog="asp-studio", description="Solve ASP files and write an interactive HTML report.")
21
+ ap.add_argument("files", nargs="+", type=pathlib.Path, help=".lp files (joined in order)")
22
+ ap.add_argument("-n", "--models", type=int, default=0, help="max answer sets (0 = all)")
23
+ ap.add_argument("-o", "--options", default="", help='extra clingo options, e.g. "--opt-mode=optN"')
24
+ ap.add_argument("-t", "--timeout", type=float, default=10.0, help="seconds (default 10)")
25
+ ap.add_argument("--out", type=pathlib.Path, help="output file (default: <first file>.html)")
26
+ ap.add_argument("--tab", default="animator", choices=["animator", "answers", "graph", "console"])
27
+ ap.add_argument("--theme", default="auto", choices=["auto", "light", "dark"])
28
+ ap.add_argument("--open", action="store_true", help="open the report in the browser")
29
+ a = ap.parse_args(argv)
30
+
31
+ if a.models < 0:
32
+ return _fail("--models must be 0 or greater")
33
+ if a.timeout <= 0:
34
+ return _fail("--timeout must be greater than 0")
35
+ for f in a.files:
36
+ if f.is_dir():
37
+ return _fail(f"{f} is a directory, not a file")
38
+ if not f.is_file():
39
+ return _fail(f"{f} does not exist")
40
+ try:
41
+ f.read_text(encoding="utf-8")
42
+ except UnicodeDecodeError as e:
43
+ return _fail(f"{f} is not UTF-8 text ({e.reason} at byte {e.start}); is it an ASP file?")
44
+ except OSError as e:
45
+ return _fail(f"cannot read {f}: {e.strerror or e}")
46
+ res = solve(list(a.files), models=a.models, options=a.options, timeout=a.timeout)
47
+
48
+ out = a.out or a.files[0].with_suffix(".html")
49
+ try:
50
+ out.parent.mkdir(parents=True, exist_ok=True)
51
+ out.write_text(page(res, tab=a.tab, theme=a.theme, max_height=100000, title=a.files[0].name), encoding="utf-8")
52
+ except OSError as e:
53
+ return _fail(f"cannot write {out}: {e.strerror or e}", 1)
54
+
55
+ for d in res.diagnostics:
56
+ print(f"{d.file}:{d.line}:{d.col}: {d.severity}: {d.message.splitlines()[0]}", file=sys.stderr)
57
+ status = res.error or f"{res.result}, {len(res)} model(s), {res.ms} ms"
58
+ print(f"{status} -> {out}")
59
+ if a.open:
60
+ webbrowser.open(out.resolve().as_uri())
61
+ return 1 if res.error else 0
62
+
63
+
64
+ if __name__ == "__main__":
65
+ raise SystemExit(main())
asp_studio/magic.py ADDED
@@ -0,0 +1,48 @@
1
+ """IPython cell magic: ``%%asp`` solves the cell and shows the visualizer."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import shlex
6
+ from typing import Any
7
+
8
+ from IPython.core.magic import Magics, cell_magic, magics_class
9
+
10
+ from .render import show
11
+ from .solver import solve
12
+
13
+
14
+ def _parser() -> argparse.ArgumentParser:
15
+ p = argparse.ArgumentParser(prog="%%asp", add_help=False)
16
+ p.add_argument("-n", "--models", type=int, default=0, help="max answer sets (0 = all)")
17
+ p.add_argument("-o", "--options", default="", help="extra clingo options, quoted")
18
+ p.add_argument("-t", "--timeout", type=float, default=10.0)
19
+ p.add_argument("--tab", default="animator", choices=["animator", "answers", "graph", "console"])
20
+ p.add_argument("--theme", default="auto", choices=["auto", "light", "dark"])
21
+ p.add_argument("--height", type=int, default=None)
22
+ p.add_argument("--out", default=None, help="store the Result in this variable")
23
+ return p
24
+
25
+
26
+ @magics_class
27
+ class AspMagics(Magics):
28
+ @cell_magic
29
+ def asp(self, line: str, cell: str) -> Any:
30
+ """Solve the cell with clingo and show answer sets, graph and animator.
31
+
32
+ %%asp [-n N] [-o "--opt-mode=optN"] [-t SECONDS] [--tab animator|answers|graph|console]
33
+ [--theme auto|light|dark] [--height PX] [--out NAME]
34
+ """
35
+ try:
36
+ a = _parser().parse_args(shlex.split(line))
37
+ except SystemExit:
38
+ print(self.asp.__doc__)
39
+ return None
40
+ res = solve(cell, models=a.models, options=a.options, timeout=a.timeout)
41
+ show(res, tab=a.tab, theme=a.theme, height=a.height)
42
+ if a.out:
43
+ self.shell.user_ns[a.out] = res
44
+ return None
45
+
46
+
47
+ def load_ipython_extension(ipython: Any) -> None:
48
+ ipython.register_magics(AspMagics)
asp_studio/render.py ADDED
@@ -0,0 +1,61 @@
1
+ """Build the visualizer page (HTML) for a :class:`~asp_studio.solver.Result`."""
2
+ from __future__ import annotations
3
+
4
+ import html as _html
5
+ import json
6
+ from importlib import resources
7
+ from typing import Any, Optional
8
+
9
+ _STATIC = ("themes.css", "webview.css")
10
+ _cache: dict = {}
11
+
12
+
13
+ def _asset(name: str) -> str:
14
+ if name not in _cache:
15
+ _cache[name] = resources.files("asp_studio").joinpath("static", name).read_text(encoding="utf-8")
16
+ return _cache[name]
17
+
18
+
19
+ def _js_json(value: Any) -> str:
20
+ """JSON that is safe to place inside a <script> element."""
21
+ return json.dumps(value).replace("</", "<\\/").replace("<!--", "<\\!--")
22
+
23
+
24
+ def page(result, *, tab: str = "animator", theme: str = "auto", max_height: int = 760, title: str = "program") -> str:
25
+ """A complete, self-contained HTML document showing the visualizer."""
26
+ init = {"state": result.to_state(), "program": result.program, "tab": tab, "theme": theme,
27
+ "maxHeight": max_height, "title": title}
28
+ css = "\n".join(_asset(n) for n in _STATIC)
29
+ return (
30
+ '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'
31
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
32
+ f"<title>ASP Studio: {_html.escape(title)}</title><style>{css}</style></head>"
33
+ '<body><div id="app"></div>'
34
+ f"<script>window.__ASP_INIT__ = {_js_json(init)};</script>"
35
+ f"<script>{_asset('engine.js')}</script>"
36
+ f"<script>{_asset('bootstrap.js')}</script>"
37
+ f"<script>{_asset('webview.js')}</script>"
38
+ "</body></html>"
39
+ )
40
+
41
+
42
+ def iframe(result, *, tab: str = "animator", theme: str = "auto", height: Optional[int] = None, max_height: int = 760) -> str:
43
+ """The page wrapped in an isolated <iframe>, ready to display in a notebook output."""
44
+ doc = page(result, tab=tab, theme=theme, max_height=max_height)
45
+ start = height if height else 420
46
+ return (
47
+ '<iframe class="asp-studio" sandbox="allow-scripts allow-same-origin" '
48
+ f'style="width:100%;height:{start}px;border:0;display:block" srcdoc="{_html.escape(doc, quote=True)}"></iframe>'
49
+ )
50
+
51
+
52
+ def show(program_or_result, *, tab: str = "animator", theme: str = "auto", height: Optional[int] = None,
53
+ max_height: int = 760, **solve_kwargs: Any) -> None:
54
+ """Display the visualizer in the current notebook cell. ``program_or_result`` may be ASP text or a Result."""
55
+ try:
56
+ from IPython.display import display
57
+ except ImportError as e: # pragma: no cover
58
+ raise ImportError("asp_studio.show needs IPython: pip install 'asp-studio[notebook]'") from e
59
+ from .solver import Result, solve
60
+ result = program_or_result if isinstance(program_or_result, Result) else solve(program_or_result, **solve_kwargs)
61
+ display({"text/html": iframe(result, tab=tab, theme=theme, height=height, max_height=max_height)}, raw=True)
asp_studio/solver.py ADDED
@@ -0,0 +1,214 @@
1
+ """Solve an ASP program with the clingo Python API and keep everything the visualizer needs."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union
9
+
10
+ import clingo
11
+
12
+ Program = Union[str, Path]
13
+
14
+ _DIAG = re.compile(
15
+ r"^(?:<block>|<string>|-):(\d+):(\d+)(?:-(\d+))?(?::(\d+)(?:-(\d+))?)?: (error|warning|info): (.*)$"
16
+ )
17
+
18
+
19
+ @dataclass
20
+ class Diagnostic:
21
+ line: int
22
+ col: int
23
+ end_col: int
24
+ severity: str
25
+ message: str
26
+ file: str = "program"
27
+
28
+
29
+ @dataclass(repr=False)
30
+ class Result:
31
+ """Outcome of :func:`solve`. Iterating yields the answer sets (lists of atom strings)."""
32
+
33
+ program: str
34
+ version: str = clingo.__version__
35
+ command: str = ""
36
+ result: str = "UNKNOWN"
37
+ models: List[List[str]] = field(default_factory=list)
38
+ costs: List[Optional[List[int]]] = field(default_factory=list)
39
+ more: bool = False
40
+ ms: int = 0
41
+ time: Optional[float] = None
42
+ stderr: str = ""
43
+ diagnostics: List[Diagnostic] = field(default_factory=list)
44
+ error: Optional[str] = None
45
+ timed_out: bool = False
46
+
47
+ def __repr__(self) -> str:
48
+ if self.error:
49
+ return f"Result(error={self.error!r})"
50
+ return f"Result({self.result}, {len(self.models)} model{'s' if len(self.models) != 1 else ''}, {self.ms} ms)"
51
+
52
+ @property
53
+ def ok(self) -> bool:
54
+ """True if the program ran without error (it may still be unsatisfiable)."""
55
+ return self.error is None
56
+
57
+ @property
58
+ def satisfiable(self) -> bool:
59
+ return self.ok and bool(self.models)
60
+
61
+ def __iter__(self):
62
+ return iter(self.models)
63
+
64
+ def __len__(self) -> int:
65
+ return len(self.models)
66
+
67
+ def __getitem__(self, i: int) -> List[str]:
68
+ return self.models[i]
69
+
70
+ def to_state(self) -> Dict[str, Any]:
71
+ """The ``solve`` object the visualizer front end renders."""
72
+ state: Dict[str, Any] = {
73
+ "engine": "python",
74
+ "version": self.version,
75
+ "command": self.command,
76
+ "result": self.result,
77
+ "models": [{"atoms": a, "costs": c} for a, c in zip(self.models, self.costs)],
78
+ "totalModels": len(self.models),
79
+ "more": self.more,
80
+ "ms": self.ms,
81
+ "time": self.time,
82
+ "stderr": self.stderr,
83
+ "capped": 0,
84
+ "diagnostics": [
85
+ {"sourceIndex": 0, "file": d.file, "line": d.line, "col": d.col, "endCol": d.end_col,
86
+ "severity": d.severity, "message": d.message}
87
+ for d in self.diagnostics
88
+ ],
89
+ }
90
+ if self.error:
91
+ state["error"] = self.error
92
+ state["timedOut"] = self.timed_out
93
+ return state
94
+
95
+ def _repr_html_(self) -> str: # picked up by Jupyter when a Result is the last expression of a cell
96
+ from .render import iframe
97
+ return iframe(self)
98
+
99
+ def show(self, **kwargs: Any) -> None:
100
+ from .render import show
101
+ show(self, **kwargs)
102
+
103
+
104
+ def _read(programs: Union[Program, Sequence[Program]]) -> Tuple[str, List[Tuple[str, int]]]:
105
+ """Join the programs; also return (name, line count) per part so messages can be mapped back to their file."""
106
+ items: Iterable[Program] = [programs] if isinstance(programs, (str, Path)) else programs
107
+ parts: List[str] = []
108
+ sources: List[Tuple[str, int]] = []
109
+ for p in items:
110
+ text = p.read_text(encoding="utf-8") if isinstance(p, Path) else p
111
+ parts.append(text)
112
+ sources.append((p.name if isinstance(p, Path) else "program", len(text.split("\n"))))
113
+ return "\n".join(parts) + "\n", sources
114
+
115
+
116
+ def _locate(sources: Sequence[Tuple[str, int]], line: int) -> Tuple[str, int]:
117
+ """Map a 1-based line of the joined program to (file name, line in that file)."""
118
+ start = 0
119
+ for name, count in sources:
120
+ if line - 1 < start + count:
121
+ return name, line - start
122
+ start += count
123
+ return (sources[-1][0], 1) if sources else ("program", line)
124
+
125
+
126
+ def _parse_messages(messages: Sequence[str], sources: Sequence[Tuple[str, int]]) -> List[Diagnostic]:
127
+ out: List[Diagnostic] = []
128
+ for msg in messages:
129
+ lines = msg.split("\n")
130
+ m = _DIAG.match(lines[0])
131
+ if not m:
132
+ continue
133
+ col = int(m.group(2))
134
+ # `L:C1-C2` marks a range on one line; `L:C1-L2:C2` spans lines, then only the start is used
135
+ end = int(m.group(3)) if m.group(3) and m.group(4) is None else col + 1
136
+ text = m.group(7)
137
+ extra = [l.strip() for l in lines[1:] if l.strip() and not l.startswith("***")]
138
+ if extra:
139
+ text += "\n" + "\n".join(extra)
140
+ sev = {"error": "error", "info": "info"}.get(m.group(6), "warning")
141
+ name, line = _locate(sources, int(m.group(1)))
142
+ out.append(Diagnostic(line, col, end, sev, text, name))
143
+ return out
144
+
145
+
146
+ def solve(
147
+ program: Union[Program, Sequence[Program]],
148
+ *,
149
+ models: int = 0,
150
+ options: Union[str, Sequence[str]] = (),
151
+ timeout: float = 10.0,
152
+ ) -> Result:
153
+ """Solve ``program`` and return a :class:`Result`.
154
+
155
+ ``program`` is ASP text, a :class:`pathlib.Path`, or a list of those (joined in order).
156
+ ``models`` limits the number of answer sets (0 = all). ``options`` are extra clingo options,
157
+ either a list or one string such as ``"--opt-mode=optN -c n=6"``. ``timeout`` is in seconds.
158
+ With optimisation statements and a finished search only the optimal models are kept.
159
+ """
160
+ text, sources = _read(program)
161
+ opts = options.split() if isinstance(options, str) else list(options)
162
+ args = [str(max(0, int(models)))] + opts
163
+ res = Result(program=text, command="clingo.Control(" + repr(args) + ")")
164
+ messages: List[str] = []
165
+ found: List[Any] = []
166
+ exhausted = False
167
+ t0 = time.perf_counter()
168
+
169
+ def on_model(m: clingo.Model) -> None:
170
+ found.append(([str(s) for s in m.symbols(shown=True)], list(m.cost) if m.cost else None, m.optimality_proven))
171
+
172
+ try:
173
+ ctl = clingo.Control(args, lambda code, msg: messages.append(msg), 50)
174
+ ctl.add("base", [], text)
175
+ ctl.ground([("base", [])])
176
+ handle = ctl.solve(on_model=on_model, async_=True)
177
+ if not handle.wait(timeout):
178
+ handle.cancel()
179
+ handle.get()
180
+ res.timed_out = True
181
+ res.error = f"Timeout after {timeout:g} s."
182
+ else:
183
+ sr = handle.get()
184
+ res.result = "SATISFIABLE" if sr.satisfiable else "UNSATISFIABLE" if sr.unsatisfiable else "UNKNOWN"
185
+ exhausted = bool(sr.exhausted)
186
+ res.more = bool(models) and len(found) >= models and not exhausted
187
+ try:
188
+ res.time = float(ctl.statistics["summary"]["times"]["total"])
189
+ except Exception: # statistics are optional
190
+ res.time = None
191
+ except RuntimeError as e:
192
+ res.error = str(e)
193
+ res.diagnostics = _parse_messages(messages, sources)
194
+ if res.error and not res.timed_out and any(d.severity == "error" for d in res.diagnostics):
195
+ res.error = "The program has a syntax or semantic error."
196
+ res.stderr = "".join(m if m.endswith("\n") else m + "\n" for m in messages)
197
+ res.ms = round((time.perf_counter() - t0) * 1000)
198
+
199
+ if res.error is None:
200
+ optimising = any(f[1] for f in found)
201
+ keep = found
202
+ if optimising and exhausted: # search finished: only the best cost is optimal
203
+ best = min(tuple(f[1] or ()) for f in found)
204
+ keep = [f for f in found if tuple(f[1] or ()) == best]
205
+ res.result = "OPTIMUM FOUND"
206
+ seen = set()
207
+ for atoms, cost, _ in keep:
208
+ key = tuple(atoms)
209
+ if key in seen:
210
+ continue
211
+ seen.add(key)
212
+ res.models.append(atoms)
213
+ res.costs.append(cost)
214
+ return res
@@ -0,0 +1,64 @@
1
+ /* Glue that lets media/webview.js run outside VS Code (notebook output or standalone page).
2
+ * Provides the small acquireVsCodeApi() surface, picks a theme and keeps the iframe height in sync. */
3
+ (function () {
4
+ "use strict";
5
+ const init = window.__ASP_INIT__ || {};
6
+ const query = new URLSearchParams(location.search); // ?theme=dark&tab=graph overrides the defaults of a standalone page
7
+ if (query.get("theme")) init.theme = query.get("theme");
8
+ if (query.get("tab")) init.tab = query.get("tab");
9
+ if (query.get("round") !== null && /^\d+$/.test(query.get("round"))) window.__ASP_START_ROUND__ = +query.get("round"); // animator starts at this round
10
+ window.__ASP_EMBED__ = true;
11
+ const root = document.documentElement;
12
+ root.classList.add("embed-doc");
13
+
14
+ /* ---- theme: explicit > VS Code variables of the host > JupyterLab > OS preference */
15
+ function applyTheme() {
16
+ if (init.theme === "light" || init.theme === "dark") { root.dataset.theme = init.theme; return; }
17
+ try {
18
+ const host = window.parent !== window ? window.parent.document : null;
19
+ if (!host) return;
20
+ const style = host.documentElement.style;
21
+ let n = 0;
22
+ for (let i = 0; i < style.length; i++) {
23
+ const k = style[i];
24
+ if (k.startsWith("--vscode-")) { root.style.setProperty(k, style.getPropertyValue(k)); n++; }
25
+ }
26
+ if (n) return;
27
+ const jp = host.body && host.body.dataset && host.body.dataset.jpThemeLight;
28
+ if (jp !== undefined) root.dataset.theme = jp === "true" ? "light" : "dark";
29
+ } catch (e) { /* cross-origin host: keep the OS preference */ }
30
+ }
31
+ applyTheme();
32
+
33
+ /* ---- messaging shim */
34
+ const TABS = { animator: "anim", anim: "anim", answers: "models", models: "models", graph: "graph", console: "console" };
35
+ let saved = { tab: TABS[init.tab] || "anim" };
36
+ window.acquireVsCodeApi = () => ({
37
+ postMessage(m) {
38
+ if (m && m.type === "ready") setTimeout(deliver, 0);
39
+ },
40
+ getState: () => saved,
41
+ setState: s => { saved = s; },
42
+ });
43
+
44
+ function deliver() {
45
+ const text = init.program || "";
46
+ const anim = text.trim() && window.AspEngine ? window.AspEngine.animationFor(text, init.title || "program") : null;
47
+ const solve = init.state || null;
48
+ window.postMessage({
49
+ type: "state", file: { name: init.title || "program", scope: "activeFile" }, status: solve && solve.error ? "error" : "done",
50
+ solve, anim, empty: !text.trim(), settings: { speed: 1400 },
51
+ }, "*");
52
+ }
53
+
54
+ /* ---- keep the surrounding iframe as tall as its content (up to maxHeight) */
55
+ function fit() {
56
+ try {
57
+ const f = window.frameElement;
58
+ if (!f) return;
59
+ const h = Math.ceil(document.body.getBoundingClientRect().height);
60
+ f.style.height = Math.min(init.maxHeight || 760, h) + "px";
61
+ } catch (e) { /* not embedded in an iframe */ }
62
+ }
63
+ addEventListener("load", () => { fit(); new ResizeObserver(fit).observe(document.body); });
64
+ })();
@@ -0,0 +1,187 @@
1
+ /* Fixpoint engine for positive ASP programs: parser (with source positions) and
2
+ * naive bottom-up evaluation, round by round. Runs in Node and in the webview. */
3
+ (function (root, factory) {
4
+ const api = factory();
5
+ if (typeof module !== "undefined" && module.exports) module.exports = api;
6
+ else root.AspEngine = api;
7
+ })(typeof self !== "undefined" ? self : this, function () {
8
+ "use strict";
9
+
10
+ class AspError extends Error {
11
+ constructor(message, line) { super(message); this.name = "AspError"; this.line = line; }
12
+ }
13
+
14
+ /** Split on `sep`, ignoring separators nested in parentheses. */
15
+ function splitTop(s, sep) {
16
+ const out = []; let depth = 0, cur = "";
17
+ for (const ch of s) {
18
+ if (ch === "(") depth++;
19
+ if (ch === ")") depth--;
20
+ if (ch === sep && depth === 0) { out.push(cur); cur = ""; } else cur += ch;
21
+ }
22
+ if (cur.trim() !== "") out.push(cur);
23
+ return out.map(x => x.trim()).filter(Boolean);
24
+ }
25
+
26
+ const isVar = t => /^[A-Z_]/.test(t);
27
+
28
+ function parseTerm(t, line) {
29
+ t = t.trim();
30
+ if (!/^[A-Za-z0-9_]+$/.test(t)) throw new AspError("Unsupported term: " + t, line);
31
+ return t;
32
+ }
33
+
34
+ function parseAtom(s, line) {
35
+ s = s.trim();
36
+ const m = s.match(/^([a-z][A-Za-z0-9_]*)\s*(?:\((.*)\))?$/s);
37
+ if (!m) throw new AspError("Cannot read atom: " + s, line);
38
+ const args = m[2] === undefined ? [] : splitTop(m[2], ",").map(t => parseTerm(t, line));
39
+ return { pred: m[1], args };
40
+ }
41
+
42
+ /** Replace `% ...` and `%* ... *%` comments by spaces so offsets and line numbers stay valid. */
43
+ function blankComments(text) {
44
+ let out = "", i = 0;
45
+ const blank = s => s.replace(/[^\n]/g, " ");
46
+ while (i < text.length) {
47
+ if (text.startsWith("%*", i)) {
48
+ const j = text.indexOf("*%", i + 2), end = j < 0 ? text.length : j + 2;
49
+ out += blank(text.slice(i, end)); i = end;
50
+ } else if (text[i] === "%") {
51
+ let j = text.indexOf("\n", i); if (j < 0) j = text.length;
52
+ out += " ".repeat(j - i); i = j;
53
+ } else out += text[i++];
54
+ }
55
+ return out;
56
+ }
57
+
58
+ const lineAt = (starts, offset) => { // 0-based line of a character offset
59
+ let lo = 0, hi = starts.length - 1;
60
+ while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (starts[mid] <= offset) lo = mid; else hi = mid - 1; }
61
+ return lo;
62
+ };
63
+
64
+ /**
65
+ * Parse a positive program.
66
+ * Returns rules `{ head, body, cmp, text, line, endLine }` (lines are 0-based) and
67
+ * `rules.incomplete`: trailing text that is not yet terminated by a dot.
68
+ */
69
+ function parseProgram(text) {
70
+ const clean = blankComments(text);
71
+ const starts = [0];
72
+ for (let i = 0; i < text.length; i++) if (text[i] === "\n") starts.push(i + 1);
73
+
74
+ const rules = [];
75
+ rules.incomplete = "";
76
+ const re = /\.(?=\s|$)/g;
77
+ let from = 0, m;
78
+ const statements = [];
79
+ while ((m = re.exec(clean))) {
80
+ if (clean[m.index - 1] === ".") continue; // range operator 1..n
81
+ statements.push([from, m.index]);
82
+ from = m.index + 1;
83
+ }
84
+ rules.incomplete = clean.slice(from).trim();
85
+
86
+ for (const [a, b] of statements) {
87
+ const raw = clean.slice(a, b);
88
+ const st = raw.trim();
89
+ if (!st) continue;
90
+ const lead = raw.length - raw.trimStart().length;
91
+ const line = lineAt(starts, a + lead), endLine = lineAt(starts, b);
92
+ if (st.startsWith("#")) continue; // #show, #const, ... are ignored
93
+ if (/\bnot\b/.test(st)) throw new AspError("Only positive programs can be animated (no `not`):\n" + st + ".", line);
94
+ if (/[{}]|\.\./.test(st)) throw new AspError("Choice rules and ranges (1..n) are not supported:\n" + st + ".", line);
95
+ const idx = st.indexOf(":-");
96
+ const head = idx < 0 ? st : st.slice(0, idx);
97
+ const bodyText = idx < 0 ? "" : st.slice(idx + 2);
98
+ if (idx >= 0 && head.trim() === "") throw new AspError("Constraints are not supported: " + st + ".", line);
99
+ const rule = { head: parseAtom(head, line), body: [], cmp: [], text: st.replace(/\s+/g, " ") + ".", line, endLine };
100
+ splitTop(bodyText, ",").forEach(l => {
101
+ const c = l.match(/^(\S+)\s*(!=|<=|>=|<|>|=)\s*(\S+)$/);
102
+ if (c) rule.cmp.push({ l: parseTerm(c[1], line), op: c[2], r: parseTerm(c[3], line) });
103
+ else rule.body.push(parseAtom(l, line));
104
+ });
105
+ const bound = new Set();
106
+ rule.body.forEach(at => at.args.forEach(t => { if (isVar(t)) bound.add(t); }));
107
+ [...rule.head.args, ...rule.cmp.flatMap(c => [c.l, c.r])].forEach(t => {
108
+ if (isVar(t) && !bound.has(t)) throw new AspError("Unsafe variable " + t + " in rule: " + rule.text, line);
109
+ });
110
+ rules.push(rule);
111
+ }
112
+ return rules;
113
+ }
114
+
115
+ const key = a => a.pred + "(" + a.args.join(",") + ")";
116
+
117
+ function holds(op, l, r) {
118
+ const num = /^-?\d+$/.test(l) && /^-?\d+$/.test(r);
119
+ const a = num ? +l : l, b = num ? +r : r;
120
+ return op === "!=" ? a !== b : op === "=" ? a === b : op === "<" ? a < b :
121
+ op === ">" ? a > b : op === "<=" ? a <= b : a >= b;
122
+ }
123
+
124
+ /**
125
+ * Naive bottom-up evaluation. `rounds[0]` holds the facts; `rounds[k]` the atoms first derived in round k.
126
+ * Each entry is `{ atom, rule, subst }` where `rule` indexes into `rules`.
127
+ */
128
+ function evaluate(rules, maxAtoms = 3000) {
129
+ const have = new Set(), byPred = {};
130
+ const rounds = [[]];
131
+ const add = a => { have.add(key(a)); (byPred[a.pred] ||= []).push(a); };
132
+ rules.forEach((r, i) => {
133
+ if (r.body.length) return;
134
+ const a = { pred: r.head.pred, args: r.head.args };
135
+ if (!have.has(key(a))) { add(a); rounds[0].push({ atom: a, rule: i, subst: {} }); }
136
+ });
137
+ for (;;) {
138
+ const fresh = [], seen = new Set();
139
+ rules.forEach((r, i) => {
140
+ if (!r.body.length) return;
141
+ const walk = (k, sub) => {
142
+ if (k === r.body.length) {
143
+ const val = t => isVar(t) ? sub[t] : t;
144
+ if (!r.cmp.every(c => holds(c.op, val(c.l), val(c.r)))) return;
145
+ const atom = { pred: r.head.pred, args: r.head.args.map(val) };
146
+ const kk = key(atom);
147
+ if (!have.has(kk) && !seen.has(kk)) { seen.add(kk); fresh.push({ atom, rule: i, subst: { ...sub } }); }
148
+ return;
149
+ }
150
+ const lit = r.body[k];
151
+ for (const fact of byPred[lit.pred] || []) {
152
+ if (fact.args.length !== lit.args.length) continue;
153
+ const s2 = { ...sub }; let ok = true;
154
+ for (let j = 0; j < lit.args.length && ok; j++) {
155
+ const t = lit.args[j];
156
+ if (isVar(t)) { if (t === "_") continue; if (t in s2) ok = s2[t] === fact.args[j]; else s2[t] = fact.args[j]; }
157
+ else ok = t === fact.args[j];
158
+ }
159
+ if (ok) walk(k + 1, s2);
160
+ }
161
+ };
162
+ walk(0, {});
163
+ });
164
+ if (!fresh.length) break;
165
+ fresh.forEach(f => add(f.atom));
166
+ rounds.push(fresh);
167
+ if (have.size > maxAtoms) throw new AspError("Too many atoms (>" + maxAtoms + "), the program may be infinite.");
168
+ }
169
+ return rounds;
170
+ }
171
+
172
+ /** The animator view for one program text, as the visualizer expects it (also used by the notebook output). */
173
+ function animationFor(text, file) {
174
+ try {
175
+ const rules = parseProgram(text), rounds = evaluate(rules);
176
+ return {
177
+ rules: rules.map(r => ({ text: r.text, file, line: r.line + 1, fact: !r.body.length })),
178
+ rounds: rounds.map(rd => rd.map(f => ({ pred: f.atom.pred, args: f.atom.args, atom: key(f.atom), rule: f.rule, subst: f.subst }))),
179
+ incomplete: rules.incomplete || "",
180
+ };
181
+ } catch (e) {
182
+ return { error: String(e.message), file, line: e.line !== undefined ? e.line + 1 : null };
183
+ }
184
+ }
185
+
186
+ return { AspError, parseProgram, evaluate, key, splitTop, isVar, animationFor };
187
+ });