From 9cf797e30b48756c0d650afe99a156e9abca6aaa Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Sun, 8 Feb 2026 17:08:13 -0600 Subject: [PATCH 1/9] introduces ucon.mcp.suggestions --- ucon/mcp/suggestions.py | 418 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 ucon/mcp/suggestions.py diff --git a/ucon/mcp/suggestions.py b/ucon/mcp/suggestions.py new file mode 100644 index 0000000..708d995 --- /dev/null +++ b/ucon/mcp/suggestions.py @@ -0,0 +1,418 @@ +# © 2026 The Radiativity Company +# Licensed under the Apache License, Version 2.0 +# See the LICENSE file for details. + +""" +ucon.mcp.suggestions +==================== + +Suggestion logic for MCP error responses, optimized for AI agent self-correction. + +This module provides helper functions for building structured error responses +with high-confidence fixes (likely_fix) and lower-confidence hints. The split +enables agents to distinguish mechanical corrections from exploratory suggestions. +""" +from __future__ import annotations + +from difflib import SequenceMatcher, get_close_matches +from typing import TYPE_CHECKING + +from pydantic import BaseModel + +if TYPE_CHECKING: + from ucon.core import Dimension, Unit, UnitProduct + + +class ConversionError(BaseModel): + """Structured error response optimized for agent self-correction. + + Attributes + ---------- + error : str + Human-readable description of what went wrong. + error_type : str + One of: "unknown_unit", "dimension_mismatch", "no_conversion_path". + parameter : str | None + Which input caused the error (e.g., "from_unit", "to_unit", "unit_a"). + got : str | None + What the agent provided (dimension or unit name). + expected : str | None + What was expected (dimension name). + likely_fix : str | None + High-confidence mechanical fix. When present, the agent should apply + it without additional reasoning. + hints : list[str] + Lower-confidence exploratory suggestions. The agent should reason + about these or escalate to the user. + """ + + error: str + error_type: str + parameter: str | None = None + got: str | None = None + expected: str | None = None + likely_fix: str | None = None + hints: list[str] = [] + + +# ----------------------------------------------------------------------------- +# Fuzzy Matching +# ----------------------------------------------------------------------------- + + +def _get_fuzzy_corpus() -> list[str]: + """All registry keys suitable for fuzzy matching. + + Returns the case-insensitive keys from _UNIT_REGISTRY. + Excludes generated scaled variants (km, MHz, etc.) to prevent dilution. + """ + from ucon.units import _UNIT_REGISTRY + + return list(_UNIT_REGISTRY.keys()) + + +def _similarity(a: str, b: str) -> float: + """SequenceMatcher ratio between two strings.""" + return SequenceMatcher(None, a, b).ratio() + + +def _suggest_units(bad_name: str) -> tuple[str | None, list[str]]: + """Fuzzy match a bad unit name against the registry. + + Parameters + ---------- + bad_name : str + The unrecognized unit string. + + Returns + ------- + tuple[str | None, list[str]] + (likely_fix, similar_names) where likely_fix is set only when + a single match scores >= 0.7. + """ + from ucon.units import _UNIT_REGISTRY + + corpus = _get_fuzzy_corpus() + matches = get_close_matches(bad_name.lower(), corpus, n=3, cutoff=0.6) + + if not matches: + return None, [] + + # Single high-confidence match → likely_fix + if len(matches) == 1: + top_score = _similarity(bad_name.lower(), matches[0]) + if top_score >= 0.7: + unit = _UNIT_REGISTRY[matches[0]] + return _format_unit_with_aliases(unit), [] + + # Multiple matches or lower confidence → hints only + formatted = [_format_unit_with_aliases(_UNIT_REGISTRY[m]) for m in matches] + return None, formatted + + +def _format_unit_with_aliases(unit: 'Unit') -> str: + """Format a unit with its shorthand for display: 'meter (m)'.""" + if unit.shorthand and unit.shorthand != unit.name: + return f"{unit.name} ({unit.shorthand})" + return unit.name + + +# ----------------------------------------------------------------------------- +# Compatible Units +# ----------------------------------------------------------------------------- + + +def _get_compatible_units(dimension: 'Dimension', limit: int = 5) -> list[str]: + """Find units with conversion paths for a given dimension. + + Walks ConversionGraph._unit_edges rather than filtering by dimension alone, + so only units with actual conversion paths are returned. + + Parameters + ---------- + dimension : Dimension + The dimension to find compatible units for. + limit : int + Maximum number of units to return. + + Returns + ------- + list[str] + Unit shorthands or names with conversion paths. + """ + from ucon.graph import get_default_graph + + graph = get_default_graph() + if dimension not in graph._unit_edges: + return [] + + units = [] + for unit in graph._unit_edges[dimension]: + # Skip RebasedUnit instances + if hasattr(unit, 'original'): + continue + label = unit.shorthand or unit.name + if label and label not in units: + units.append(label) + if len(units) >= limit: + break + return units + + +def _get_dimension_name(unit) -> str: + """Get readable dimension name from a Unit or UnitProduct. + + Named dimensions return their name (e.g., 'velocity'). + Unnamed derived dimensions return 'derived(length^3/time)'. + Never returns 'Vector(...)'. + + Parameters + ---------- + unit : Unit or UnitProduct + The unit to get the dimension name for. + + Returns + ------- + str + Human-readable dimension name. + """ + dim = unit.dimension + return dim.name + + +# ----------------------------------------------------------------------------- +# Error Builders +# ----------------------------------------------------------------------------- + + +def build_unknown_unit_error(bad_name: str, parameter: str) -> ConversionError: + """Build a ConversionError for an unknown unit. + + Parameters + ---------- + bad_name : str + The unrecognized unit string. + parameter : str + Which parameter was bad (e.g., "from_unit", "to_unit"). + + Returns + ------- + ConversionError + Structured error with fuzzy match suggestions. + """ + likely_fix, similar = _suggest_units(bad_name) + + hints = [] + if similar: + hints.append(f"Similar units: {', '.join(similar)}") + elif not likely_fix: + hints.append("No similar units found") + hints.append("Use list_units() to see all available units") + + # Generic hints always included + hints.append("For scaled variants, combine with a prefix: km, mm, µm (see list_scales)") + hints.append("For composite units: m/s, kg*m/s^2") + + # Limit to 3 hints + hints = hints[:3] + + return ConversionError( + error=f"Unknown unit: '{bad_name}'", + error_type="unknown_unit", + parameter=parameter, + likely_fix=likely_fix, + hints=hints, + ) + + +def build_dimension_mismatch_error( + from_unit_str: str, + to_unit_str: str, + src_unit, + dst_unit, +) -> ConversionError: + """Build a ConversionError for a dimension mismatch. + + Parameters + ---------- + from_unit_str : str + The source unit string as provided by the user. + to_unit_str : str + The target unit string as provided by the user. + src_unit : Unit or UnitProduct + The parsed source unit. + dst_unit : Unit or UnitProduct + The parsed target unit. + + Returns + ------- + ConversionError + Structured error with dimension info and compatible units. + """ + src_dim_name = _get_dimension_name(src_unit) + dst_dim_name = _get_dimension_name(dst_unit) + + # Build hints + hints = [f"{from_unit_str} is {src_dim_name}; {to_unit_str} is {dst_dim_name}"] + + # Get compatible units for source dimension + compatible = _get_compatible_units(src_unit.dimension) + if compatible: + hints.append(f"Compatible {src_dim_name} units: {', '.join(compatible)}") + else: + hints.append("These are fundamentally different physical quantities") + + hints.append("Use check_dimensions() to verify compatibility before converting") + + # Limit to 3 hints + hints = hints[:3] + + return ConversionError( + error=f"Cannot convert '{from_unit_str}' to '{to_unit_str}': " + f"{src_dim_name} is not compatible with {dst_dim_name}", + error_type="dimension_mismatch", + parameter="to_unit", + got=src_dim_name, + expected=src_dim_name, # Expected same dimension as source + hints=hints, + ) + + +def build_no_path_error( + from_unit_str: str, + to_unit_str: str, + src_unit, + dst_unit, + exception: Exception, +) -> ConversionError: + """Build a ConversionError for a missing conversion path. + + Parameters + ---------- + from_unit_str : str + The source unit string as provided by the user. + to_unit_str : str + The target unit string as provided by the user. + src_unit : Unit or UnitProduct + The parsed source unit. + dst_unit : Unit or UnitProduct + The parsed target unit. + exception : Exception + The ConversionNotFound exception. + + Returns + ------- + ConversionError + Structured error explaining why conversion is impossible. + """ + src_dim = src_unit.dimension + dst_dim = dst_unit.dimension + src_dim_name = _get_dimension_name(src_unit) + dst_dim_name = _get_dimension_name(dst_unit) + + hints = [f"{from_unit_str} is {src_dim_name}; {to_unit_str} is {dst_dim_name}"] + + # Check if this is pseudo-dimension isolation + exc_msg = str(exception) + is_pseudo_isolation = "pseudo-dimension" in exc_msg.lower() + + if is_pseudo_isolation or (src_dim != dst_dim and src_dim.vector == dst_dim.vector): + # Pseudo-dimension isolation (angle, ratio, solid_angle share zero vector) + hints.append( + f"{src_dim_name} and {dst_dim_name} are isolated pseudo-dimensions — " + "they cannot interconvert" + ) + + # Provide workaround hints based on the specific pseudo-dimensions + if src_dim_name == "angle": + hints.append("To express an angle as a fraction, compute angle/(2π) explicitly") + other_units = _get_compatible_units(src_dim) + if other_units: + hints.append(f"Other angle units: {', '.join(other_units)}") + elif src_dim_name == "ratio": + other_units = _get_compatible_units(src_dim) + if other_units: + hints.append(f"Other ratio units: {', '.join(other_units)}") + elif src_dim_name == "solid_angle": + other_units = _get_compatible_units(src_dim) + if other_units: + hints.append(f"Other solid angle units: {', '.join(other_units)}") + + elif src_dim == dst_dim: + # Same dimension but missing edge — suggest intermediate + hints.append( + "Both units are in the same dimension, but no direct conversion edge exists" + ) + hints.append("Convert via an intermediate: try converting to a base unit first") + compatible = _get_compatible_units(src_dim) + if compatible: + hints.append(f"Other {src_dim_name} units with paths: {', '.join(compatible)}") + + else: + # Different dimensions — shouldn't normally reach here (would be DimensionMismatch) + hints.append("These units have different dimensions") + hints.append("Use check_dimensions() to verify compatibility before converting") + + # Limit to 3 hints + hints = hints[:3] + + return ConversionError( + error=f"No conversion path from '{from_unit_str}' to '{to_unit_str}'", + error_type="no_conversion_path", + parameter=None, + got=src_dim_name, + expected=dst_dim_name, + hints=hints, + ) + + +def build_unknown_dimension_error(bad_dimension: str) -> ConversionError: + """Build a ConversionError for an unknown dimension filter. + + Parameters + ---------- + bad_dimension : str + The unrecognized dimension string. + + Returns + ------- + ConversionError + Structured error with similar dimension suggestions. + """ + from ucon import Dimension + + known = [d.name for d in Dimension] + matches = get_close_matches(bad_dimension.lower(), [k.lower() for k in known], n=3, cutoff=0.6) + + # Map back to proper case + matches_proper = [] + for m in matches: + for k in known: + if k.lower() == m: + matches_proper.append(k) + break + + likely_fix = matches_proper[0] if len(matches_proper) == 1 else None + hints = [] + + if matches_proper and not likely_fix: + hints.append(f"Similar dimensions: {', '.join(matches_proper)}") + elif not matches_proper: + hints.append("Use list_dimensions() to see all available dimensions") + + return ConversionError( + error=f"Unknown dimension: '{bad_dimension}'", + error_type="unknown_unit", + parameter="dimension", + likely_fix=likely_fix, + hints=hints, + ) + + +__all__ = [ + "ConversionError", + "build_unknown_unit_error", + "build_dimension_mismatch_error", + "build_no_path_error", + "build_unknown_dimension_error", +] From 60c01fabe66600ec63084320a7dc3ffea1476cf5 Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Sun, 8 Feb 2026 17:20:28 -0600 Subject: [PATCH 2/9] mcp tools implement new suggestions approach --- ucon/mcp/server.py | 63 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/ucon/mcp/server.py b/ucon/mcp/server.py index b972794..d6440a0 100644 --- a/ucon/mcp/server.py +++ b/ucon/mcp/server.py @@ -11,6 +11,14 @@ from ucon import Dimension, get_unit_by_name from ucon.core import Number, Scale, Unit, UnitProduct +from ucon.graph import DimensionMismatch, ConversionNotFound +from ucon.mcp.suggestions import ( + ConversionError, + build_unknown_unit_error, + build_dimension_mismatch_error, + build_no_path_error, + build_unknown_dimension_error, +) from ucon.units import UnknownUnitError @@ -63,7 +71,7 @@ class DimensionCheck(BaseModel): @mcp.tool() -def convert(value: float, from_unit: str, to_unit: str) -> ConversionResult: +def convert(value: float, from_unit: str, to_unit: str) -> ConversionResult | ConversionError: """ Convert a numeric value from one unit to another. @@ -80,16 +88,28 @@ def convert(value: float, from_unit: str, to_unit: str) -> ConversionResult: Returns: ConversionResult with converted quantity, unit, and dimension. - - Raises: - UnknownUnitError: If a unit string cannot be parsed. - DimensionMismatch: If units have incompatible dimensions. + ConversionError if the conversion fails, with suggestions for correction. """ - src = get_unit_by_name(from_unit) - dst = get_unit_by_name(to_unit) - - num = Number(quantity=value, unit=src) - result = num.to(dst) + # 1. Parse source unit + try: + src = get_unit_by_name(from_unit) + except UnknownUnitError: + return build_unknown_unit_error(from_unit, parameter="from_unit") + + # 2. Parse target unit + try: + dst = get_unit_by_name(to_unit) + except UnknownUnitError: + return build_unknown_unit_error(to_unit, parameter="to_unit") + + # 3. Perform conversion + try: + num = Number(quantity=value, unit=src) + result = num.to(dst) + except DimensionMismatch: + return build_dimension_mismatch_error(from_unit, to_unit, src, dst) + except ConversionNotFound as e: + return build_no_path_error(from_unit, to_unit, src, dst, e) # Use the target unit string as output (what the user asked for). # This handles cases like mg/kg → µg/kg where internal representation @@ -106,7 +126,7 @@ def convert(value: float, from_unit: str, to_unit: str) -> ConversionResult: @mcp.tool() -def list_units(dimension: str | None = None) -> list[UnitInfo]: +def list_units(dimension: str | None = None) -> list[UnitInfo] | ConversionError: """ List available units, optionally filtered by dimension. @@ -119,9 +139,16 @@ def list_units(dimension: str | None = None) -> list[UnitInfo]: Returns: List of UnitInfo objects describing available units. + ConversionError if the dimension filter is invalid. """ import ucon.units as units_module + # Validate dimension filter if provided + if dimension: + known_dimensions = [d.name for d in Dimension] + if dimension not in known_dimensions: + return build_unknown_dimension_error(dimension) + # Units that accept SI scale prefixes SCALABLE_UNITS = { "meter", "gram", "second", "ampere", "kelvin", "mole", "candela", @@ -188,7 +215,7 @@ def list_scales() -> list[ScaleInfo]: @mcp.tool() -def check_dimensions(unit_a: str, unit_b: str) -> DimensionCheck: +def check_dimensions(unit_a: str, unit_b: str) -> DimensionCheck | ConversionError: """ Check if two units have compatible dimensions. @@ -201,9 +228,17 @@ def check_dimensions(unit_a: str, unit_b: str) -> DimensionCheck: Returns: DimensionCheck indicating compatibility and the dimension of each unit. + ConversionError if a unit string cannot be parsed. """ - a = get_unit_by_name(unit_a) - b = get_unit_by_name(unit_b) + try: + a = get_unit_by_name(unit_a) + except UnknownUnitError: + return build_unknown_unit_error(unit_a, parameter="unit_a") + + try: + b = get_unit_by_name(unit_b) + except UnknownUnitError: + return build_unknown_unit_error(unit_b, parameter="unit_b") dim_a = a.dimension if isinstance(a, Unit) else a.dimension dim_b = b.dimension if isinstance(b, Unit) else b.dimension From 550d5b8493ebd78dd73e42f38eaa82b8d2f79592 Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Sun, 8 Feb 2026 17:22:46 -0600 Subject: [PATCH 3/9] updates ucon.mcp.server test in light of new suggestions approach --- tests/ucon/mcp/test_server.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/ucon/mcp/test_server.py b/tests/ucon/mcp/test_server.py index 77e1b62..b56d75a 100644 --- a/tests/ucon/mcp/test_server.py +++ b/tests/ucon/mcp/test_server.py @@ -78,7 +78,9 @@ class TestConvertToolErrors(unittest.TestCase): def setUpClass(cls): try: from ucon.mcp.server import convert + from ucon.mcp.suggestions import ConversionError cls.convert = staticmethod(convert) + cls.ConversionError = ConversionError cls.skip_tests = False except ImportError: cls.skip_tests = True @@ -88,22 +90,24 @@ def setUp(self): self.skipTest("mcp not installed") def test_unknown_source_unit(self): - """Test that unknown source unit raises error.""" - from ucon.units import UnknownUnitError - with self.assertRaises(UnknownUnitError): - self.convert(1, "foobar", "m") + """Test that unknown source unit returns ConversionError.""" + result = self.convert(1, "foobar", "m") + self.assertIsInstance(result, self.ConversionError) + self.assertEqual(result.error_type, "unknown_unit") + self.assertEqual(result.parameter, "from_unit") def test_unknown_target_unit(self): - """Test that unknown target unit raises error.""" - from ucon.units import UnknownUnitError - with self.assertRaises(UnknownUnitError): - self.convert(1, "m", "bazqux") + """Test that unknown target unit returns ConversionError.""" + result = self.convert(1, "m", "bazqux") + self.assertIsInstance(result, self.ConversionError) + self.assertEqual(result.error_type, "unknown_unit") + self.assertEqual(result.parameter, "to_unit") def test_dimension_mismatch(self): - """Test that incompatible dimensions raise error.""" - from ucon.graph import DimensionMismatch - with self.assertRaises(DimensionMismatch): - self.convert(1, "m", "s") + """Test that incompatible dimensions return ConversionError.""" + result = self.convert(1, "m", "s") + self.assertIsInstance(result, self.ConversionError) + self.assertEqual(result.error_type, "dimension_mismatch") class TestListUnitsTool(unittest.TestCase): From d141575c3a5d752130c10419f1c3df956df3e666 Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Sun, 8 Feb 2026 17:28:56 -0600 Subject: [PATCH 4/9] adds new test cases --- tests/ucon/mcp/test_server.py | 168 ++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/tests/ucon/mcp/test_server.py b/tests/ucon/mcp/test_server.py index b56d75a..afec497 100644 --- a/tests/ucon/mcp/test_server.py +++ b/tests/ucon/mcp/test_server.py @@ -351,5 +351,173 @@ def test_sorted(self): self.assertEqual(result, sorted(result)) +class TestConvertToolSuggestions(unittest.TestCase): + """Test suggestion features in the convert tool.""" + + @classmethod + def setUpClass(cls): + try: + from ucon.mcp.server import convert + from ucon.mcp.suggestions import ConversionError + cls.convert = staticmethod(convert) + cls.ConversionError = ConversionError + cls.skip_tests = False + except ImportError: + cls.skip_tests = True + + def setUp(self): + if self.skip_tests: + self.skipTest("mcp not installed") + + def test_typo_single_match(self): + """Test that typo with single high-confidence match gets likely_fix.""" + result = self.convert(100, "meetr", "ft") + self.assertIsInstance(result, self.ConversionError) + self.assertEqual(result.error_type, "unknown_unit") + self.assertEqual(result.parameter, "from_unit") + self.assertIsNotNone(result.likely_fix) + self.assertIn("meter", result.likely_fix) + + def test_bad_to_unit(self): + """Test that typo in to_unit position is detected.""" + result = self.convert(100, "meter", "feeet") + self.assertIsInstance(result, self.ConversionError) + self.assertEqual(result.parameter, "to_unit") + # Should suggest "foot" + self.assertTrue( + (result.likely_fix and "foot" in result.likely_fix) or + any("foot" in h for h in result.hints) + ) + + def test_unrecognizable_no_spurious_matches(self): + """Test that completely unknown unit doesn't produce spurious matches.""" + result = self.convert(100, "xyzzy", "kg") + self.assertIsInstance(result, self.ConversionError) + self.assertIsNone(result.likely_fix) + self.assertTrue(any("list_units" in h for h in result.hints)) + + def test_dimension_mismatch_readable(self): + """Test that dimension mismatch error uses readable names.""" + result = self.convert(100, "meter", "second") + self.assertIsInstance(result, self.ConversionError) + self.assertEqual(result.error_type, "dimension_mismatch") + self.assertEqual(result.got, "length") + self.assertIn("length", result.error) + self.assertIn("time", result.error) + self.assertNotIn("Vector", result.error) + + def test_derived_dimension_readable(self): + """Test that derived dimension uses readable name in error.""" + result = self.convert(1, "m/s", "kg") + self.assertIsInstance(result, self.ConversionError) + self.assertIn("velocity", result.error) + self.assertNotIn("Vector", result.error) + + def test_unnamed_derived_dimension(self): + """Test that unnamed derived dimension doesn't show Vector.""" + result = self.convert(1, "m^3/s", "kg") + self.assertIsInstance(result, self.ConversionError) + # Should show readable format, not Vector(...) + self.assertNotIn("Vector", result.error) + # Should have some dimension info + self.assertTrue("length" in result.error or "derived(" in result.error) + + def test_pseudo_dimension_explains_isolation(self): + """Test that pseudo-dimension isolation is explained.""" + result = self.convert(1, "radian", "percent") + self.assertIsInstance(result, self.ConversionError) + self.assertEqual(result.error_type, "no_conversion_path") + self.assertEqual(result.got, "angle") + self.assertEqual(result.expected, "ratio") + self.assertTrue( + any("cannot interconvert" in h or "isolated" in h for h in result.hints) + ) + + def test_compatible_units_in_hints(self): + """Test that dimension mismatch includes compatible units.""" + result = self.convert(100, "meter", "second") + self.assertIsInstance(result, self.ConversionError) + # Should suggest compatible length units + hints_str = str(result.hints) + self.assertTrue( + "ft" in hints_str or "in" in hints_str or + "foot" in hints_str or "inch" in hints_str + ) + + def test_no_vector_in_any_error(self): + """Test that no error response contains raw Vector representation.""" + cases = [ + ("m^3/s", "kg"), + ("kg*m/s^2", "A"), + ] + for from_u, to_u in cases: + result = self.convert(1, from_u, to_u) + if isinstance(result, self.ConversionError): + self.assertNotIn("Vector(", result.error) + for h in result.hints: + self.assertNotIn("Vector(", h) + + +class TestCheckDimensionsErrors(unittest.TestCase): + """Test error handling in the check_dimensions tool.""" + + @classmethod + def setUpClass(cls): + try: + from ucon.mcp.server import check_dimensions + from ucon.mcp.suggestions import ConversionError + cls.check_dimensions = staticmethod(check_dimensions) + cls.ConversionError = ConversionError + cls.skip_tests = False + except ImportError: + cls.skip_tests = True + + def setUp(self): + if self.skip_tests: + self.skipTest("mcp not installed") + + def test_bad_unit_a(self): + """Test that bad unit_a returns ConversionError.""" + result = self.check_dimensions("meetr", "foot") + self.assertIsInstance(result, self.ConversionError) + self.assertEqual(result.parameter, "unit_a") + + def test_bad_unit_b(self): + """Test that bad unit_b returns ConversionError.""" + result = self.check_dimensions("meter", "fooot") + self.assertIsInstance(result, self.ConversionError) + self.assertEqual(result.parameter, "unit_b") + + +class TestListUnitsErrors(unittest.TestCase): + """Test error handling in the list_units tool.""" + + @classmethod + def setUpClass(cls): + try: + from ucon.mcp.server import list_units + from ucon.mcp.suggestions import ConversionError + cls.list_units = staticmethod(list_units) + cls.ConversionError = ConversionError + cls.skip_tests = False + except ImportError: + cls.skip_tests = True + + def setUp(self): + if self.skip_tests: + self.skipTest("mcp not installed") + + def test_bad_dimension_filter(self): + """Test that bad dimension filter returns ConversionError.""" + result = self.list_units(dimension="lenth") + self.assertIsInstance(result, self.ConversionError) + self.assertEqual(result.parameter, "dimension") + # Should suggest "length" + self.assertTrue( + (result.likely_fix and "length" in result.likely_fix) or + any("length" in h for h in result.hints) + ) + + if __name__ == '__main__': unittest.main() From 05bf80d6d9def12acc38eeafe61146430c009022 Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Sun, 8 Feb 2026 17:31:00 -0600 Subject: [PATCH 5/9] ensures that the best match above threshold is recorded as likely_fix --- ucon/mcp/suggestions.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/ucon/mcp/suggestions.py b/ucon/mcp/suggestions.py index 708d995..f7501be 100644 --- a/ucon/mcp/suggestions.py +++ b/ucon/mcp/suggestions.py @@ -87,8 +87,9 @@ def _suggest_units(bad_name: str) -> tuple[str | None, list[str]]: Returns ------- tuple[str | None, list[str]] - (likely_fix, similar_names) where likely_fix is set only when - a single match scores >= 0.7. + (likely_fix, similar_names) where likely_fix is set when + the top match scores >= 0.7 and is significantly better than + alternatives. Ambiguous matches go to hints only. """ from ucon.units import _UNIT_REGISTRY @@ -98,14 +99,29 @@ def _suggest_units(bad_name: str) -> tuple[str | None, list[str]]: if not matches: return None, [] - # Single high-confidence match → likely_fix - if len(matches) == 1: - top_score = _similarity(bad_name.lower(), matches[0]) - if top_score >= 0.7: + # Check top match score + top_score = _similarity(bad_name.lower(), matches[0]) + + # High-confidence top match (>= 0.7) with clear gap to second match → likely_fix + if top_score >= 0.7: + # If there's a second match, check if top is clearly better + if len(matches) >= 2: + second_score = _similarity(bad_name.lower(), matches[1]) + # Gap of 0.1 means top match is clearly the intended unit + if top_score - second_score >= 0.1: + unit = _UNIT_REGISTRY[matches[0]] + # Include other matches as hints + other_formatted = [ + _format_unit_with_aliases(_UNIT_REGISTRY[m]) + for m in matches[1:] + ] + return _format_unit_with_aliases(unit), other_formatted + else: + # Single match at >= 0.7 → definitely likely_fix unit = _UNIT_REGISTRY[matches[0]] return _format_unit_with_aliases(unit), [] - # Multiple matches or lower confidence → hints only + # Multiple matches with similar scores or lower confidence → hints only formatted = [_format_unit_with_aliases(_UNIT_REGISTRY[m]) for m in matches] return None, formatted From 0949a6b811b39adb4a1eb8e33d387244be82c9d8 Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Sun, 8 Feb 2026 17:32:09 -0600 Subject: [PATCH 6/9] more expressive hints --- ucon/mcp/suggestions.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ucon/mcp/suggestions.py b/ucon/mcp/suggestions.py index f7501be..3c1223b 100644 --- a/ucon/mcp/suggestions.py +++ b/ucon/mcp/suggestions.py @@ -219,9 +219,15 @@ def build_unknown_unit_error(bad_name: str, parameter: str) -> ConversionError: likely_fix, similar = _suggest_units(bad_name) hints = [] - if similar: + + # If we have a likely_fix but also other similar units, mention them + if likely_fix and similar: + hints.append(f"Other similar units: {', '.join(similar)}") + elif similar: + # No likely_fix, just hints hints.append(f"Similar units: {', '.join(similar)}") elif not likely_fix: + # No matches at all hints.append("No similar units found") hints.append("Use list_units() to see all available units") From d0932029b4769488df1f449ef0f717cf1e4e815f Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Sun, 8 Feb 2026 17:35:27 -0600 Subject: [PATCH 7/9] updates ROADMAP --- ROADMAP.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7bfea05..8b955e2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,7 +28,7 @@ ucon is a dimensional analysis library for engineers building systems where unit | v0.6.0 | Pydantic + Serialization | Complete | | v0.6.x | MCP Server | Complete | | v0.6.x | LogMap + Nines | Complete | -| v0.6.x | Dimensional Type Safety | Planned | +| v0.6.x | Dimensional Type Safety | Complete | | v0.7.0 | NumPy Array Support | Planned | | v0.8.0 | String Parsing | Planned | | v0.9.0 | Constants + Logarithmic Units | Planned | @@ -221,7 +221,7 @@ Building on v0.5.x baseline: --- -## v0.6.x — Dimensional Type Safety (Planned) +## v0.6.x — Dimensional Type Safety (Complete) **Theme:** Type-directed validation for AI agents and domain formulas. @@ -229,13 +229,18 @@ Building on v0.5.x baseline: - [x] `Number[Dimension]` type-safe generics via `typing.Annotated` - [x] `DimConstraint` marker class for annotation introspection - [x] `@enforce_dimensions` decorator for runtime validation at function boundaries -- [ ] MCP error suggestions with actionable recovery hints +- [x] MCP error suggestions with actionable recovery hints +- [x] `ConversionError` response model with `likely_fix` and `hints` +- [x] Fuzzy matching for unknown units with confidence tiers +- [x] Compatible unit suggestions from graph edges +- [x] Pseudo-dimension isolation explanation **Outcomes:** - Dimension errors caught at function boundaries with clear messages - AI agents can self-correct via readable error diagnostics - Domain authors declare dimensional constraints declaratively, not imperatively - MCP server returns structured errors with fuzzy-matched suggestions +- High-confidence fixes enable single-retry correction loops - Foundation for schema-level dimension constraints in MCP tools --- From b859ef9ec0965a4eb99edd86913cacc4d2f30042 Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Mon, 9 Feb 2026 07:37:44 -0600 Subject: [PATCH 8/9] revamps ROADMAP --- ROADMAP.md | 63 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8b955e2..a81d5b4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,10 +29,11 @@ ucon is a dimensional analysis library for engineers building systems where unit | v0.6.x | MCP Server | Complete | | v0.6.x | LogMap + Nines | Complete | | v0.6.x | Dimensional Type Safety | Complete | -| v0.7.0 | NumPy Array Support | Planned | +| v0.7.0 | MCP Error Suggestions | Complete | +| v0.7.x | MCP Compute + Schema Constraints | Planned | | v0.8.0 | String Parsing | Planned | | v0.9.0 | Constants + Logarithmic Units | Planned | -| v0.10.0 | DataFrame Integration | Planned | +| v0.10.0 | Scientific Computing | Planned | | v1.0.0 | API Stability | Planned | --- @@ -223,41 +224,53 @@ Building on v0.5.x baseline: ## v0.6.x — Dimensional Type Safety (Complete) -**Theme:** Type-directed validation for AI agents and domain formulas. +**Theme:** Type-directed validation for domain formulas. - [x] Human-readable derived dimension names (`derived(length^3/time)` not `Vector(...)`) - [x] `Number[Dimension]` type-safe generics via `typing.Annotated` - [x] `DimConstraint` marker class for annotation introspection - [x] `@enforce_dimensions` decorator for runtime validation at function boundaries -- [x] MCP error suggestions with actionable recovery hints + +**Outcomes:** +- Dimension errors caught at function boundaries with clear messages +- Domain authors declare dimensional constraints declaratively, not imperatively +- Foundation for MCP error suggestions and schema-level constraints + +--- + +## v0.7.0 — MCP Error Suggestions (Complete) + +**Theme:** AI agent self-correction. + - [x] `ConversionError` response model with `likely_fix` and `hints` -- [x] Fuzzy matching for unknown units with confidence tiers +- [x] Fuzzy matching for unknown units with confidence tiers (≥0.7 for `likely_fix`) - [x] Compatible unit suggestions from graph edges - [x] Pseudo-dimension isolation explanation +- [x] `ucon/mcp/suggestions.py` module (independently testable) +- [x] Error handling in `convert()`, `check_dimensions()`, `list_units()` **Outcomes:** -- Dimension errors caught at function boundaries with clear messages -- AI agents can self-correct via readable error diagnostics -- Domain authors declare dimensional constraints declaratively, not imperatively -- MCP server returns structured errors with fuzzy-matched suggestions +- MCP tools return structured errors instead of raw exceptions - High-confidence fixes enable single-retry correction loops -- Foundation for schema-level dimension constraints in MCP tools +- AI agents can self-correct via readable error diagnostics +- Foundation for schema-level dimension constraints --- -## v0.7.0 — NumPy Array Support +## v0.7.x — MCP Compute + Schema Constraints (Planned) -**Theme:** Scientific computing integration. +**Theme:** Domain formulas for AI agents. -- [ ] `Number` wraps `np.ndarray` values -- [ ] Vectorized conversion -- [ ] Vectorized arithmetic with uncertainty propagation -- [ ] Performance benchmarks +- [ ] `compute` tool for dimensionally-validated domain formulas +- [ ] Schema-level dimension constraints (expose `DimConstraint` in MCP tool schemas) +- [ ] Per-parameter error localization in multi-input formulas +- [ ] Formula registration/discovery mechanism **Outcomes:** -- Seamless integration with NumPy-based scientific workflows -- Efficient batch conversions for large datasets -- Performance characteristics documented and optimized +- AI agents can run domain formulas (FIB-4, dosage calculations, etc.) with dimensional safety +- MCP schemas declare expected dimensions per parameter +- LLMs can validate inputs before calling, reducing round-trips +- Completes the type-directed correction loop --- @@ -295,19 +308,25 @@ Building on v0.5.x baseline: --- -## v0.10.0 — DataFrame Integration +## v0.10.0 — Scientific Computing -**Theme:** Data science workflows. +**Theme:** NumPy and DataFrame integration. +- [ ] `Number` wraps `np.ndarray` values +- [ ] Vectorized conversion and arithmetic +- [ ] Vectorized uncertainty propagation - [ ] Polars integration: `NumberColumn` type - [ ] Pandas integration: `NumberSeries` type - [ ] Column-wise conversion - [ ] Unit-aware arithmetic on columns +- [ ] Performance benchmarks **Outcomes:** +- Seamless integration with NumPy-based scientific workflows +- Efficient batch conversions for large datasets - First-class support for data science workflows - Unit-safe transformations on tabular data -- Interoperability with modern DataFrame ecosystems +- Performance characteristics documented and optimized --- From f3b60d234edee1b320d55549de546d054110f31c Mon Sep 17 00:00:00 2001 From: "Emmanuel I. Obi" Date: Mon, 9 Feb 2026 07:46:00 -0600 Subject: [PATCH 9/9] gives resolution to v0.7.x --- ROADMAP.md | 48 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a81d5b4..4a076bd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -257,20 +257,54 @@ Building on v0.5.x baseline: --- -## v0.7.x — MCP Compute + Schema Constraints (Planned) +## v0.7.1 — Pre-Compute Foundations (Planned) -**Theme:** Domain formulas for AI agents. +**Theme:** Architectural prerequisites for multi-step factor-label chains. -- [ ] `compute` tool for dimensionally-validated domain formulas -- [ ] Schema-level dimension constraints (expose `DimConstraint` in MCP tool schemas) -- [ ] Per-parameter error localization in multi-input formulas -- [ ] Formula registration/discovery mechanism +- [ ] SI symbol coverage audit (ensure `A`, `V`, `W`, etc. in case-sensitive registry) +- [ ] Add `step: int | None` field to `ConversionError` for chain error localization +- [ ] Extract `_resolve_unit(name, parameter)` helper to reduce try/except duplication +- [ ] Add `build_parse_error` builder for malformed composite expressions +- [ ] Document priority alias invariant for contributors + +**Outcomes:** +- Expressions like `V/mA`, `mA·h`, `µA/cm²` resolve correctly +- Error responses can localize failures to specific steps in a chain +- MCP server code is DRY and ready for compute's N-factor resolution +- `ParseError` wrapped in structured `ConversionError` like other error types + +--- + +## v0.7.2 — Compute Tool (Planned) + +**Theme:** Multi-step factor-label calculations for AI agents. + +- [ ] `compute` tool for dimensionally-validated factor-label chains +- [ ] `steps` array in response showing intermediate dimensional state +- [ ] Per-step error localization using `ConversionError.step` +- [ ] Multi-factor cancellation tests for `UnitProduct` (medical dosage, stoichiometry) + +**Outcomes:** +- AI agents can run factor-label chains with dimensional safety at each step +- Intermediate state visible for debugging and benchmarks (SLM vs LLM comparison) +- Agents can self-correct mid-chain rather than only at the end +- `UnitProduct` cancellation logic validated against realistic compute inputs + +--- + +## v0.7.x — Schema-Level Dimension Constraints (Planned) + +**Theme:** Pre-call validation for AI agents. + +- [ ] Expose `DimConstraint` in MCP tool schemas +- [ ] Schema generator introspects dimension constraints from `@enforce_dimensions` functions +- [ ] Formula registration/discovery mechanism for domain packages **Outcomes:** -- AI agents can run domain formulas (FIB-4, dosage calculations, etc.) with dimensional safety - MCP schemas declare expected dimensions per parameter - LLMs can validate inputs before calling, reducing round-trips - Completes the type-directed correction loop +- Foundation for ucon.dev marketplace of domain formula packages ---