diff --git a/docs/developer/guides/mcp-server.md b/docs/developer/guides/mcp-server.md index 439fa0db..f59c58bf 100644 --- a/docs/developer/guides/mcp-server.md +++ b/docs/developer/guides/mcp-server.md @@ -40,6 +40,8 @@ pixi run -e python -m pip install mcp | `uw_transcript_key` | the key to the run, Markdown or text | | `uw_transcript_adjoint_segments` | the run partitioned by adjoint support | | `uw_describe_render` | any description record in another form | +| `uw_capabilities` | every solver family with its equation templates, terms and conditions, every constitutive model with its parameters, every history scheme | +| `uw_capability` | one family in full, documentation included, as markdown, text or yaml | `path` may be a transcript file, a run directory, or a `transcripts` directory, in which case the latest run is read. diff --git a/docs/developer/subsystems/describe-and-view.md b/docs/developer/subsystems/describe-and-view.md index f6e27b2a..cc3ef659 100644 --- a/docs/developer/subsystems/describe-and-view.md +++ b/docs/developer/subsystems/describe-and-view.md @@ -61,6 +61,36 @@ them as a nested "where" list under each form. `view()` on a class, or `view(class_documentation=True)` on an instance, shows the class documentation as well. +## At the class level + +A family describes itself with no instance and no mesh: + +```python +uw.systems.Stokes.view() # the equation templates, terms, conditions, documentation +uw.systems.Stokes.describe_class() # the same as data +uw.constitutive_models.ViscoPlasticFlowModel.describe_class() # parameters with symbol, units, description +uw.systems.ddt.SemiLagrangian.describe_class() # the scheme and its defaults +``` + +`describe_class()` reads what the class declares: the residual templates +(`F0`, `F1`, `PF0`) with their symbols and descriptions, `_solver_terms`, +the `add_*_bc` methods, the parameter descriptors of a constitutive +model's `_Parameters`, and the docstring, which becomes `documentation`. +`view()` on a class renders it, and `view(class_documentation=True)` on an +instance renders the family before the instance. + +The catalogue of every family is one record, built from those: + +```python +uw.view(uw.capabilities()) # every family, one line each +uw.view(uw.capabilities("solvers", detail="full"), depth=2) +uw.capabilities("constitutive_models") # the record, for a query +``` + +The MCP server serves the same record, so "can Underworld solve this" is +answered from the classes, in a notebook or from a tool, and cannot drift +from them. + ## Adding a description to a class Override `describe(self, depth=4)` and return a record built with diff --git a/src/underworld3/__init__.py b/src/underworld3/__init__.py index 68969932..ab31c3a5 100644 --- a/src/underworld3/__init__.py +++ b/src/underworld3/__init__.py @@ -222,7 +222,8 @@ def view(): ThermalConvectionConfig, create_thermal_convection_model, ) -from .utilities.describe import render +from .utilities.describe import render, view +from .utilities.capabilities import capabilities from .utilities.transcript_query import Transcript from .utilities.transcript_report import ( transcript_diagram, diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index 0d1067ae..6b249965 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -739,6 +739,29 @@ def _build_c_tensor(self): return + @classmethod + def describe_class(cls, depth=4): + """The family: its parameters, with symbol, units and description, + from the descriptors on its ``_Parameters`` class, and its + documentation — with no instance.""" + from underworld3.utilities.describe import record + from underworld3.utilities._api_tools import ExpressionDescriptor + doc = (cls.__doc__ or "").strip() + terms = [] + params = getattr(cls, "_Parameters", None) + if params is not None: + seen = set() + for base in params.__mro__: + for key, attr in base.__dict__.items(): + if isinstance(attr, ExpressionDescriptor) and key not in seen: + seen.add(key) + terms.append({"name": key, "symbol": getattr(attr, "name", None), "latex": None, + "text": None, "units": getattr(attr, "units", None), + "description": (getattr(attr, "description", "") or "").strip(), + "where": []}) + return record("constitutive_model_family", cls.__name__, doc.split("\n")[0], + documentation=doc or None, terms=terms or None) + def describe(self, depth=4): """What this constitutive model is, as data: its parameters as terms, with the named expressions inside each followed to ``depth``, and diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 10841d93..6e125533 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -53,6 +53,16 @@ expression = lambda *x, **X: public_expression(*x, _unique_name_generation=True, from underworld3.function.expressions import unwrap_expression as _unwrap_expression +def _public_names(cls): + """The names ``uw.systems`` exports a solver class under.""" + try: + systems = uw.systems + except AttributeError: + return [] + return sorted(name for name, obj in vars(systems).items() + if obj is cls and not name.startswith("SNES_")) + + def _jacobian_unwrap(expr): """Expand UWexpressions down to (but NOT including) constant atoms, for use as the input to a Jacobian derivative (``derive_by_array`` / ``diff``). @@ -1474,6 +1484,59 @@ class SolverBaseClass(uw_object): }) return terms + @classmethod + def describe_class(cls, depth=4): + """The family: the equation it solves as the residual templates + declared on the class, the terms it is given, the conditions it + accepts, and its documentation — with no instance and no mesh.""" + import inspect + from underworld3.utilities.describe import record + from underworld3.utilities._api_tools import Template + + doc = (cls.__doc__ or "").strip() + facts = {} + public = _public_names(cls) + if public: + facts["public name"] = public[0] if len(public) == 1 else public + for base, what in (("SNES_Stokes_SaddlePt", "velocity and pressure, a saddle point"), + ("SNES_MultiComponent", "several components"), + ("SNES_Vector", "a vector field"), ("SNES_Scalar", "a scalar field")): + if any(b.__name__ == base for b in cls.__mro__): + facts["unknown"] = what + break + try: + params = inspect.signature(cls.__init__).parameters + facts["time dependent"] = any(p in params for p in ("DuDt", "DFDt", "order")) + except (TypeError, ValueError): + pass + forms = {} + for name in ("F0", "F1", "PF0"): + declared = None + for base in cls.__mro__: + if name in base.__dict__: + declared = base.__dict__[name] + break + if declared is None: + continue + if isinstance(declared, Template): + forms[name] = {"symbol": declared.name, "latex": None, "text": None, + "description": (declared.description or "").strip().split("\n")[0], "where": []} + elif isinstance(declared, property): + forms[name] = {"symbol": name, "latex": None, "text": None, + "description": (declared.__doc__ or "").strip().split("\n")[0], "where": []} + terms = [{"name": attr, "symbol": None, "latex": None, "text": None, "units": None, + "description": what, "where": []} + for attr, what in (getattr(cls, "_solver_terms", None) or ())] + conditions = [] + for method in sorted(m for m in dir(cls) if m.startswith("add_") and m.endswith("_bc")): + fn = getattr(cls, method, None) + conditions.append({"mechanism": method, "type": method[4:-3].replace("_", " "), + "boundary": "any", "latex": None, + "text": (getattr(fn, "__doc__", "") or "").strip().split("\n")[0] or None}) + return record("solver_family", cls.__name__, doc.split("\n")[0], documentation=doc or None, + facts=facts, forms=forms or None, terms=terms or None, + conditions=conditions or None, terms_declared=bool(terms)) + def describe(self, depth=4): """What this solver solves, as data. diff --git a/src/underworld3/mcp/__init__.py b/src/underworld3/mcp/__init__.py index 9194009c..6832b778 100644 --- a/src/underworld3/mcp/__init__.py +++ b/src/underworld3/mcp/__init__.py @@ -301,5 +301,38 @@ def uw_describe_render(record_yaml: str, format: str = "markdown", depth: int = return f"error: {exc}" +@server.tool(name="uw_capabilities", annotations=_READ_ONLY) +def uw_capabilities(kind: str = "all") -> str: + """What Underworld3 can solve: every solver family with the residual + templates it declares, the terms it is given and the conditions it + accepts; every constitutive model with its parameters; every transport + history scheme. kind is all, solvers, constitutive_models or + histories. One line each; uw_capability gives the whole of one. The + same catalogue a notebook gets from uw.capabilities().""" + from ..utilities.capabilities import capabilities + try: + return _yaml(capabilities(kind)) + except ValueError as exc: + return f"error: {exc}" + + +@server.tool(name="uw_capability", annotations=_READ_ONLY) +def uw_capability(name: str, format: str = "markdown") -> str: + """One family in full: its documentation, equation templates, terms, + parameters and conditions, rendered as markdown, text or yaml. name is + a public name from uw_capabilities, such as Stokes, AdvDiffusion, + ViscoPlasticFlowModel or SemiLagrangian.""" + from ..utilities.capabilities import family + d = family(name) + if d is None: + return f"error: no family named {name!r}; uw_capabilities lists them" + if format == "yaml": + return _yaml(d) + try: + return render(d, format) + except ValueError as exc: + return f"error: {exc}" + + def main(): server.run(transport="stdio") diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index 2c63c33a..fa6645ca 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -557,6 +557,23 @@ class _DDtBase(uw_object): Symbolic, Eulerian, SemiLagrangian). """ + @classmethod + def describe_class(cls, depth=4): + """The scheme: its documentation and the arguments that set its + order and weighting — with no instance.""" + import inspect + from underworld3.utilities.describe import record + doc = (cls.__doc__ or "").strip() + facts = {"scheme": cls.__name__} + try: + params = inspect.signature(cls.__init__).parameters + for key in ("order", "theta"): + if key in params and params[key].default is not inspect.Parameter.empty: + facts[f"default {key}"] = params[key].default + except (TypeError, ValueError): + pass + return record("history_family", cls.__name__, doc.split("\n")[0], documentation=doc or None, facts=facts) + def describe(self, depth=4): """What this history is, as data: the scheme (the class), its order, its weighting, the field it tracks and the history slots it keeps. diff --git a/src/underworld3/utilities/_api_tools.py b/src/underworld3/utilities/_api_tools.py index 190579b0..3b3b79f3 100644 --- a/src/underworld3/utilities/_api_tools.py +++ b/src/underworld3/utilities/_api_tools.py @@ -569,20 +569,16 @@ def view(self_or_cls, format=None, depth=None, class_documentation=False): ``class_documentation=True``, the class documentation is shown too. """ import inspect - from .docstring_utils import render_docstring, in_jupyter + from .docstring_utils import in_jupyter + from .describe import view as _view if inspect.isclass(self_or_cls) or class_documentation: - rendered = render_docstring(self_or_cls.__doc__, target="auto") - if in_jupyter(): - from IPython.display import Markdown, display - display(Markdown(rendered)) - if class_documentation: - display(Markdown("---")) - else: - print(rendered) - if class_documentation: - print("---") - if inspect.isclass(self_or_cls): - return + # the family: what this class solves, is given, and accepts, + # with its documentation — the same record for a class and for + # class_documentation=True on an instance + cls = self_or_cls if inspect.isclass(self_or_cls) else type(self_or_cls) + _view(cls.describe_class(), format=format, depth=depth) + if inspect.isclass(self_or_cls): + return if type(self_or_cls).describe is not uw_object.describe: from .describe import view as _view _view(self_or_cls, format=format, depth=depth) @@ -607,6 +603,20 @@ def describe(self, depth=4): doc = (type(self).__doc__ or "").strip().split("\n")[0] return record(type(self).__name__.lower(), getattr(self, "name", None), doc) + @classmethod + def describe_class(cls, depth=4): + """What this KIND of object is, as data, without an instance: the + class, the first line of its documentation as the summary, and the + documentation itself. A solver family adds the equation it solves, + the terms it is given and the conditions it accepts; a constitutive + model its parameters. ``view()`` on a class renders this, and the + capabilities catalogue is built from it. + """ + from .describe import record + doc = (cls.__doc__ or "").strip() + return record(f"{cls.__name__.lower()}_class", cls.__name__, doc.split("\n")[0], + documentation=doc or None) + # placeholder def _object_viewer(self): from IPython.display import Latex, Markdown, display diff --git a/src/underworld3/utilities/capabilities.py b/src/underworld3/utilities/capabilities.py new file mode 100644 index 00000000..2ef7a5d4 --- /dev/null +++ b/src/underworld3/utilities/capabilities.py @@ -0,0 +1,95 @@ +r"""What Underworld3 can solve, discovered from the classes. + +Every solver, constitutive model and history family describes itself at +the class level (``describe_class()``): the equation it declares, the terms +it is given, the conditions it accepts, its parameters, its documentation. +:func:`capabilities` gathers those into one record, so a notebook, a note +and the MCP server read the same catalogue, and none of it is written by +hand. + + uw.view(uw.capabilities()) # every family, one line each + uw.view(uw.capabilities("solvers", detail="full"), depth=2) + uw.capabilities("constitutive_models")["children"][0]["children"] +""" + +import inspect + +from .describe import record + +GROUPS = ("solvers", "constitutive_models", "histories") + + +def families(): + """Every solver, constitutive model and history family the package + exports, by public name: ``{"solvers": {...}, "constitutive_models": + {...}, "histories": {...}}``.""" + import underworld3 as uw + from underworld3.cython.generic_solvers import SolverBaseClass + + out = {group: {} for group in GROUPS} + for name, obj in vars(uw.systems).items(): + if inspect.isclass(obj) and issubclass(obj, SolverBaseClass) and not name.startswith("SNES_"): + out["solvers"][name] = obj + base = uw.constitutive_models.Constitutive_Model + for name, obj in vars(uw.constitutive_models).items(): + if inspect.isclass(obj) and issubclass(obj, base) and obj is not base: + out["constitutive_models"][name] = obj + for name, obj in vars(uw.systems.ddt).items(): + if inspect.isclass(obj) and issubclass(obj, uw.systems.ddt._DDtBase) and not name.startswith("_"): + out["histories"][name] = obj + return out + + +def _summary_row(name, description): + """A family reduced to what a catalogue line needs.""" + facts = dict(description.get("facts") or {}) + facts.pop("public name", None) + if description.get("forms"): + facts["equation"] = ", ".join(f"{k}: {v.get('description') or v.get('symbol')}" + for k, v in description["forms"].items()) + if description.get("terms"): + facts["given"] = [t["name"] for t in description["terms"]] + if description.get("conditions"): + facts["conditions"] = [c.get("mechanism") for c in description["conditions"]] + return record(description.get("kind", "family"), name, description.get("summary", ""), + facts={"class": description.get("name"), **facts}) + + +def capabilities(kind="all", detail="summary"): + """The catalogue as a description record: a child per group, and a + child per family under it. ``kind`` is ``"all"`` or one of + ``"solvers"``, ``"constitutive_models"``, ``"histories"``. With + ``detail="summary"`` each family is one line with its equation names, + terms and conditions; with ``"full"`` each is its whole + ``describe_class()`` record, documentation included.""" + found = families() + if kind != "all" and kind not in found: + raise ValueError(f"kind must be 'all' or one of {GROUPS}, not {kind!r}") + if detail not in ("summary", "full"): + raise ValueError("detail must be 'summary' or 'full'") + groups = [] + for group in GROUPS: + if kind not in ("all", group): + continue + members = [] + for name, cls in sorted(found[group].items()): + description = cls.describe_class() + members.append(description if detail == "full" else _summary_row(name, description)) + groups.append(record(group, None, f"{len(members)} {group.replace('_', ' ')}", children=members)) + total = sum(len(g["children"]) for g in groups) + return record("capabilities", "underworld3", + f"{total} families: " + ", ".join(f"{len(g['children'])} {g['kind'].replace('_', ' ')}" + for g in groups), + children=groups) + + +def family(name): + """One family's full class-level description, by public name or class + name, or ``None``.""" + for members in families().values(): + cls = members.get(name) + if cls is None: + cls = next((c for c in members.values() if c.__name__ == name), None) + if cls is not None: + return cls.describe_class() + return None diff --git a/src/underworld3/utilities/describe.py b/src/underworld3/utilities/describe.py index 7ccbb102..5b8429e8 100644 --- a/src/underworld3/utilities/describe.py +++ b/src/underworld3/utilities/describe.py @@ -172,6 +172,13 @@ def _render_lines(d, mode, level, depth): elif d.get("terms_declared") is False: out.append("") out.append(_para(_emph("this solver does not declare the terms it was given", mode), mode, level)) + # a class's documentation, after what it solves and accepts, without + # the first line the summary already gave + documentation = d.get("documentation") or "" + body = documentation.strip().split("\n", 1)[1] if "\n" in documentation.strip() else "" + if body.strip(): + out.append("") + out.extend(_documentation_lines(body, mode, level)) children = d.get("children") or [] if children and (depth is None or level < depth): for child in children: @@ -180,6 +187,20 @@ def _render_lines(d, mode, level, depth): return out +def _documentation_lines(text, mode, level): + """A class's documentation in the mode's markup: the docstring + renderer's Markdown for a notebook, its terminal text otherwise.""" + from underworld3.utilities.docstring_utils import render_docstring + try: + rendered = render_docstring(text, target="jupyter" if mode == "markdown" else "terminal") + except Exception: + rendered = str(text) + if mode == "latex": + rendered = _tex_text(rendered) + lines = rendered.rstrip().splitlines() + return [(" " * level + l) if mode == "text" else l for l in lines] + + def _title(d): kind = str(d.get("kind") or "object").replace("_", " ") name = d.get("name") @@ -253,13 +274,15 @@ def _form_lines(name, form, mode, level): symbol = form.get("symbol") or name what = form.get("description") or "" if mode == "text": - text = form.get("text", "") - out.append(" " * level + f" {name}: {_plain_math(text)}") + text = form.get("text") + line = f" {name}: {_plain_math(text)}" if text else f" {name} = {_plain_math(symbol)}" + out.append(" " * level + line) if what: out.append(" " * level + f" {what}") else: out.append("") - out.append(_math(f"{symbol} = {form.get('latex', '')}", mode, display=True)) + latex = form.get("latex") + out.append(_math(f"{symbol} = {latex}" if latex else str(symbol), mode, display=True)) if what: out.append(_emph(what, mode)) where = _where_lines(form.get("where", []), mode, level + 1) diff --git a/tests/test_0017_describe_and_render.py b/tests/test_0017_describe_and_render.py index d4ffc634..8df51077 100644 --- a/tests/test_0017_describe_and_render.py +++ b/tests/test_0017_describe_and_render.py @@ -124,3 +124,53 @@ def test_the_transcript_part_record_is_still_a_part(tmp_path, objects): records = [json.loads(l) for l in (tmp_path / "run.jsonl").read_text().splitlines()] parts = [r for r in records if r.get("kind") == "part"] assert parts and "children" not in parts[0] and parts[0]["forms"] + + +def _solver_classes(): + from underworld3.cython.generic_solvers import SolverBaseClass + found = {} + + def walk(cls): + for sub in cls.__subclasses__(): + found[sub.__name__] = sub + walk(sub) + walk(SolverBaseClass) + return found + + +def test_every_family_describes_itself_at_the_class_level(capsys): + """The class carries what the family solves, is given and accepts, with + no instance: what the capabilities catalogue is built from.""" + for name, cls in _solver_classes().items(): + d = cls.describe_class() + assert d["kind"] == "solver_family" and d["name"] == name and d["summary"], name + assert d.get("conditions"), f"{name} declares no boundary-condition methods" + for fmt in FORMATS: + assert render(d, fmt).strip() + stokes = uw.systems.Stokes.describe_class() + assert set(stokes["forms"]) == {"F0", "F1", "PF0"} and stokes["facts"]["public name"] == "Stokes" + assert {t["name"] for t in stokes["terms"]} >= {"bodyforce", "penalty"} + uw.systems.Stokes.view() + out = capsys.readouterr().out + assert "solver family SNES_Stokes" in out and "Boundary conditions" in out and "F1" in out + for cls in (uw.constitutive_models.ViscoPlasticFlowModel, uw.constitutive_models.DiffusionModel): + d = cls.describe_class() + assert d["kind"] == "constitutive_model_family" and {t["name"] for t in d["terms"]} + assert "yield_stress" in {t["name"] for t in uw.constitutive_models.ViscoPlasticFlowModel.describe_class()["terms"]} + d = uw.systems.ddt.SemiLagrangian.describe_class() + assert d["kind"] == "history_family" and d["facts"]["scheme"] == "SemiLagrangian" + + +def test_the_capabilities_catalogue_is_the_families_in_one_record(capsys): + cat = uw.capabilities() + assert cat["kind"] == "capabilities" and [g["kind"] for g in cat["children"]] == [ + "solvers", "constitutive_models", "histories"] + solvers = {c["name"]: c for c in cat["children"][0]["children"]} + assert "Stokes" in solvers and solvers["Stokes"]["facts"]["class"] == "SNES_Stokes" + assert "given" in solvers["Stokes"]["facts"] and "conditions" in solvers["Stokes"]["facts"] + full = uw.capabilities("solvers", detail="full") + assert full["children"][0]["children"][0].get("documentation") + uw.view(uw.capabilities("histories")) + assert "SemiLagrangian" in capsys.readouterr().out + with pytest.raises(ValueError): + uw.capabilities("nothing") diff --git a/tests/test_0019_mcp_server.py b/tests/test_0019_mcp_server.py index 06f95214..5b23feef 100644 --- a/tests/test_0019_mcp_server.py +++ b/tests/test_0019_mcp_server.py @@ -96,3 +96,18 @@ def test_the_tools_answer_as_yaml(run_path): assert "Poisson" in uwmcp.uw_transcript_key(p, format="text") rendered = uwmcp.uw_describe_render(uwmcp.uw_transcript_summary(p), format="markdown") assert rendered.startswith("## transcript") + + +def test_the_capabilities_catalogue_comes_from_the_classes(): + cat = yaml.safe_load(uwmcp.uw_capabilities()) + groups = {g["kind"]: g["children"] for g in cat["children"]} + assert set(groups) == {"solvers", "constitutive_models", "histories"} + stokes = next(r for r in groups["solvers"] if r["name"] == "Stokes") + assert "F0" in stokes["facts"]["equation"] and "add_essential_bc" in stokes["facts"]["conditions"] + assert any(r["name"] == "ViscoPlasticFlowModel" for r in groups["constitutive_models"]) + assert any(r["name"] == "SemiLagrangian" for r in groups["histories"]) + assert "error" in uwmcp.uw_capabilities(kind="nothing") + full = uwmcp.uw_capability("Stokes") + assert full.startswith("## solver family SNES_Stokes") and "Boundary conditions" in full + assert yaml.safe_load(uwmcp.uw_capability("SemiLagrangian", format="yaml"))["kind"] == "history_family" + assert "error" in uwmcp.uw_capability("Nothing")