From 4c966010cd6b2cf4c99195aea59fdcf34b903c3d Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 27 Sep 2026 10:06:44 -0700 Subject: [PATCH 1/2] describe_class(): the family's equation, terms and conditions from the class; view() on a class renders it view() on a class rendered the docstring and nothing else, and the instance view rendered the description: two paths. Now a family describes itself with no instance and no mesh. describe_class() reads what the class declares: the residual templates with their symbols and descriptions, the terms in _solver_terms, the add_*_bc methods it accepts, a constitutive model's parameter descriptors with symbol, units and description, a history scheme's defaults, and the docstring as its documentation. view() on a class renders that record, and class_documentation=True on an instance renders the family before the instance, so the two views are one renderer over two records. The MCP server gains uw_capabilities, the catalogue of every solver, constitutive model and history family built from those records, and uw_capability for one family in full. "Can Underworld solve this" is answered from the classes and cannot drift from them. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V --- docs/developer/guides/mcp-server.md | 2 + .../developer/subsystems/describe-and-view.md | 20 ++++++ src/underworld3/constitutive_models.py | 23 ++++++ .../cython/petsc_generic_snes_solvers.pyx | 63 ++++++++++++++++ src/underworld3/mcp/__init__.py | 72 +++++++++++++++++++ src/underworld3/systems/ddt.py | 17 +++++ src/underworld3/utilities/_api_tools.py | 36 ++++++---- src/underworld3/utilities/describe.py | 29 +++++++- tests/test_0017_describe_and_render.py | 35 +++++++++ tests/test_0019_mcp_server.py | 14 ++++ 10 files changed, 295 insertions(+), 16 deletions(-) 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..e21bd4ce 100644 --- a/docs/developer/subsystems/describe-and-view.md +++ b/docs/developer/subsystems/describe-and-view.md @@ -61,6 +61,26 @@ 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. This is what the +capabilities catalogue on the MCP server is built from, so "can Underworld +solve this" is answered from the classes 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/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..c15a5726 100644 --- a/src/underworld3/mcp/__init__.py +++ b/src/underworld3/mcp/__init__.py @@ -301,5 +301,77 @@ def uw_describe_render(record_yaml: str, format: str = "markdown", depth: int = return f"error: {exc}" +def _families(): + """Every solver, constitutive model and history family, by public name.""" + import inspect + import underworld3 as uw + from underworld3.cython.generic_solvers import SolverBaseClass + out = {"solvers": {}, "constitutive_models": {}, "histories": {}} + 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 + for name, obj in vars(uw.constitutive_models).items(): + if (inspect.isclass(obj) and issubclass(obj, uw.constitutive_models.Constitutive_Model) + and obj is not uw.constitutive_models.Constitutive_Model): + 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 + + +@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 of documentation each; uw_capability gives the + whole of one.""" + families = _families() + if kind != "all" and kind not in families: + return f"error: kind must be one of all, {', '.join(families)}" + out = {} + for group, members in families.items(): + if kind not in ("all", group): + continue + rows = [] + for name, cls in sorted(members.items()): + d = cls.describe_class() + row = {"name": name, "class": cls.__name__, "summary": d.get("summary")} + if d.get("facts"): + row.update({k: v for k, v in d["facts"].items() if k != "public name"}) + if d.get("forms"): + row["equation"] = {k: f"{v.get('symbol')}: {v.get('description')}" for k, v in d["forms"].items()} + if d.get("terms"): + row["given"] = [t["name"] for t in d["terms"]] + if d.get("conditions"): + row["conditions"] = [c["mechanism"] for c in d["conditions"]] + rows.append(row) + out[group] = rows + return _yaml(out) + + +@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.""" + for group, members in _families().items(): + 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: + d = cls.describe_class() + if format == "yaml": + return _yaml(d) + try: + return render(d, format) + except ValueError as exc: + return f"error: {exc}" + return f"error: no family named {name!r}; uw_capabilities lists them" + + 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/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..3091c567 100644 --- a/tests/test_0017_describe_and_render.py +++ b/tests/test_0017_describe_and_render.py @@ -124,3 +124,38 @@ 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" diff --git a/tests/test_0019_mcp_server.py b/tests/test_0019_mcp_server.py index 06f95214..b633135e 100644 --- a/tests/test_0019_mcp_server.py +++ b/tests/test_0019_mcp_server.py @@ -96,3 +96,17 @@ 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()) + assert set(cat) == {"solvers", "constitutive_models", "histories"} + stokes = next(r for r in cat["solvers"] if r["name"] == "Stokes") + assert set(stokes["equation"]) == {"F0", "F1", "PF0"} and "add_essential_bc" in stokes["conditions"] + assert any(r["name"] == "ViscoPlasticFlowModel" for r in cat["constitutive_models"]) + assert any(r["name"] == "SemiLagrangian" for r in cat["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") From 3407d4794e0f3ee6b3b2fb2c703db0561668a869 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 27 Sep 2026 10:09:26 -0700 Subject: [PATCH 2/2] uw.capabilities(): the catalogue of families in the library, one record for notebooks and the server The class-level description exists for discovery, so the catalogue built from it belongs in the library rather than in the MCP server. uw.capabilities(kind, detail) gathers every solver, constitutive model and history family into one description record, a child per group and a child per family, one line each or the whole class record; uw.view renders any record, so a notebook reads the same catalogue a tool does. The server's uw_capabilities and uw_capability are now consumers of it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Na7qBenCp67rDTZhFGTh5V --- .../developer/subsystems/describe-and-view.md | 16 +++- src/underworld3/__init__.py | 3 +- src/underworld3/mcp/__init__.py | 73 ++++---------- src/underworld3/utilities/capabilities.py | 95 +++++++++++++++++++ tests/test_0017_describe_and_render.py | 15 +++ tests/test_0019_mcp_server.py | 11 ++- 6 files changed, 148 insertions(+), 65 deletions(-) create mode 100644 src/underworld3/utilities/capabilities.py diff --git a/docs/developer/subsystems/describe-and-view.md b/docs/developer/subsystems/describe-and-view.md index e21bd4ce..cc3ef659 100644 --- a/docs/developer/subsystems/describe-and-view.md +++ b/docs/developer/subsystems/describe-and-view.md @@ -77,9 +77,19 @@ uw.systems.ddt.SemiLagrangian.describe_class() # the scheme and 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. This is what the -capabilities catalogue on the MCP server is built from, so "can Underworld -solve this" is answered from the classes and cannot drift from them. +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 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/mcp/__init__.py b/src/underworld3/mcp/__init__.py index c15a5726..6832b778 100644 --- a/src/underworld3/mcp/__init__.py +++ b/src/underworld3/mcp/__init__.py @@ -301,55 +301,19 @@ def uw_describe_render(record_yaml: str, format: str = "markdown", depth: int = return f"error: {exc}" -def _families(): - """Every solver, constitutive model and history family, by public name.""" - import inspect - import underworld3 as uw - from underworld3.cython.generic_solvers import SolverBaseClass - out = {"solvers": {}, "constitutive_models": {}, "histories": {}} - 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 - for name, obj in vars(uw.constitutive_models).items(): - if (inspect.isclass(obj) and issubclass(obj, uw.constitutive_models.Constitutive_Model) - and obj is not uw.constitutive_models.Constitutive_Model): - 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 - - @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 of documentation each; uw_capability gives the - whole of one.""" - families = _families() - if kind != "all" and kind not in families: - return f"error: kind must be one of all, {', '.join(families)}" - out = {} - for group, members in families.items(): - if kind not in ("all", group): - continue - rows = [] - for name, cls in sorted(members.items()): - d = cls.describe_class() - row = {"name": name, "class": cls.__name__, "summary": d.get("summary")} - if d.get("facts"): - row.update({k: v for k, v in d["facts"].items() if k != "public name"}) - if d.get("forms"): - row["equation"] = {k: f"{v.get('symbol')}: {v.get('description')}" for k, v in d["forms"].items()} - if d.get("terms"): - row["given"] = [t["name"] for t in d["terms"]] - if d.get("conditions"): - row["conditions"] = [c["mechanism"] for c in d["conditions"]] - rows.append(row) - out[group] = rows - return _yaml(out) + 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) @@ -358,19 +322,16 @@ def uw_capability(name: str, format: str = "markdown") -> str: parameters and conditions, rendered as markdown, text or yaml. name is a public name from uw_capabilities, such as Stokes, AdvDiffusion, ViscoPlasticFlowModel or SemiLagrangian.""" - for group, members in _families().items(): - 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: - d = cls.describe_class() - if format == "yaml": - return _yaml(d) - try: - return render(d, format) - except ValueError as exc: - return f"error: {exc}" - return f"error: no family named {name!r}; uw_capabilities lists them" + 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(): 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/tests/test_0017_describe_and_render.py b/tests/test_0017_describe_and_render.py index 3091c567..8df51077 100644 --- a/tests/test_0017_describe_and_render.py +++ b/tests/test_0017_describe_and_render.py @@ -159,3 +159,18 @@ def test_every_family_describes_itself_at_the_class_level(capsys): 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 b633135e..5b23feef 100644 --- a/tests/test_0019_mcp_server.py +++ b/tests/test_0019_mcp_server.py @@ -100,11 +100,12 @@ def test_the_tools_answer_as_yaml(run_path): def test_the_capabilities_catalogue_comes_from_the_classes(): cat = yaml.safe_load(uwmcp.uw_capabilities()) - assert set(cat) == {"solvers", "constitutive_models", "histories"} - stokes = next(r for r in cat["solvers"] if r["name"] == "Stokes") - assert set(stokes["equation"]) == {"F0", "F1", "PF0"} and "add_essential_bc" in stokes["conditions"] - assert any(r["name"] == "ViscoPlasticFlowModel" for r in cat["constitutive_models"]) - assert any(r["name"] == "SemiLagrangian" for r in cat["histories"]) + 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