From 5f0437d6e58315a00fb8074896d8d253f10e2c90 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 8 Jun 2026 16:30:40 +0100 Subject: [PATCH 01/38] Move some basic typing things to its own module --- medcat-v2/medcat/components/base.py | 61 ++++++++++++++++++++++++++++ medcat-v2/medcat/components/types.py | 56 +------------------------ 2 files changed, 63 insertions(+), 54 deletions(-) create mode 100644 medcat-v2/medcat/components/base.py diff --git a/medcat-v2/medcat/components/base.py b/medcat-v2/medcat/components/base.py new file mode 100644 index 000000000..5ee0282c9 --- /dev/null +++ b/medcat-v2/medcat/components/base.py @@ -0,0 +1,61 @@ +from typing import Protocol, runtime_checkable, Optional +from typing_extensions import Self +from enum import Enum, auto + +from medcat.tokenizing.tokens import MutableDocument +from medcat.tokenizing.tokenizers import BaseTokenizer +from medcat.cdb import CDB +from medcat.vocab import Vocab +from medcat.config.config import ComponentConfig + + +@runtime_checkable +class BaseComponent(Protocol): + + @property + def full_name(self) -> Optional[str]: + """Name with the component type (e.g ner, linking, meta).""" + pass + + @property + def name(self) -> str: + """The name of the component.""" + pass + + def is_core(self) -> bool: + """Whether the component is a core component or not. + + Returns: + bool: Whether this is a core component. + """ + pass + + def __call__(self, doc: MutableDocument) -> MutableDocument: + pass + + @classmethod + def create_new_component( + cls, cnf: ComponentConfig, tokenizer: BaseTokenizer, + cdb: CDB, vocab: Vocab, model_load_path: Optional[str]) -> Self: + """Create a new component or load one off disk if load path presented. + + This may raise an exception if the wrong type of config is provided. + + Args: + cnf (ComponentConfig): The config relevant to this components. + tokenizer (BaseTokenizer): The base tokenizer. + cdb (CDB): The CDB. + vocab (Vocab): The Vocab. + model_load_path (Optional[str]): Model load path (if present). + + Returns: + Self: The new components. + """ + pass + + +class CoreComponentType(Enum): + tagging = auto() + token_normalizing = auto() + ner = auto() + linking = auto() diff --git a/medcat-v2/medcat/components/types.py b/medcat-v2/medcat/components/types.py index fe26bb1e0..939bafff9 100644 --- a/medcat-v2/medcat/components/types.py +++ b/medcat-v2/medcat/components/types.py @@ -1,7 +1,6 @@ from typing import Optional, Protocol, Callable, runtime_checkable, Union from typing import Literal -from typing_extensions import Self -from enum import Enum, auto +from enum import Enum from abc import ABC, abstractmethod from medcat.utils.registry import Registry, MedCATRegistryException @@ -10,58 +9,7 @@ from medcat.cdb import CDB from medcat.vocab import Vocab from medcat.config.config import ComponentConfig - - -class CoreComponentType(Enum): - tagging = auto() - token_normalizing = auto() - ner = auto() - linking = auto() - - -@runtime_checkable -class BaseComponent(Protocol): - - @property - def full_name(self) -> Optional[str]: - """Name with the component type (e.g ner, linking, meta).""" - pass - - @property - def name(self) -> str: - """The name of the component.""" - pass - - def is_core(self) -> bool: - """Whether the component is a core component or not. - - Returns: - bool: Whether this is a core component. - """ - pass - - def __call__(self, doc: MutableDocument) -> MutableDocument: - pass - - @classmethod - def create_new_component( - cls, cnf: ComponentConfig, tokenizer: BaseTokenizer, - cdb: CDB, vocab: Vocab, model_load_path: Optional[str]) -> Self: - """Create a new component or load one off disk if load path presented. - - This may raise an exception if the wrong type of config is provided. - - Args: - cnf (ComponentConfig): The config relevant to this components. - tokenizer (BaseTokenizer): The base tokenizer. - cdb (CDB): The CDB. - vocab (Vocab): The Vocab. - model_load_path (Optional[str]): Model load path (if present). - - Returns: - Self: The new components. - """ - pass +from medcat.components.base import BaseComponent, CoreComponentType @runtime_checkable From faa9fb02a9689f009c1bc618614f7fdd542b80f8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 11:14:20 +0100 Subject: [PATCH 02/38] Add initial contracting bits --- medcat-v2/medcat/components/base.py | 44 ++++++- medcat-v2/medcat/components/contracting.py | 134 +++++++++++++++++++++ 2 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 medcat-v2/medcat/components/contracting.py diff --git a/medcat-v2/medcat/components/base.py b/medcat-v2/medcat/components/base.py index 5ee0282c9..ff80e97f2 100644 --- a/medcat-v2/medcat/components/base.py +++ b/medcat-v2/medcat/components/base.py @@ -1,12 +1,13 @@ from typing import Protocol, runtime_checkable, Optional from typing_extensions import Self -from enum import Enum, auto +from enum import Enum from medcat.tokenizing.tokens import MutableDocument from medcat.tokenizing.tokenizers import BaseTokenizer from medcat.cdb import CDB from medcat.vocab import Vocab from medcat.config.config import ComponentConfig +from medcat.components.contracting import ComponentContract, CollectionContract @runtime_checkable @@ -55,7 +56,40 @@ def create_new_component( class CoreComponentType(Enum): - tagging = auto() - token_normalizing = auto() - ner = auto() - linking = auto() + tagging = ComponentContract( + needs=frozenset(), + must_provide=frozenset(), + # doesn't write for every token + may_provide=frozenset({'token.is_punctuation', 'token.to_skip'}), + collection_contracts=frozenset(), + ) + token_normalizing = ComponentContract( + needs=frozenset(), + # should write for every token + must_provide=frozenset({'token.norm'}), + may_provide=frozenset(), + collection_contracts=frozenset(), + ) + ner = ComponentContract( + needs=frozenset({'token.to_skip'}), + must_provide=frozenset({'doc.ner_ents'}), # the list must exist + may_provide=frozenset(), + collection_contracts=frozenset({ + CollectionContract( + field='doc.ner_ents', + must_provide=frozenset({'detected_name'}), + ) + }), + ) + linking = ComponentContract( + needs=frozenset({'doc.ner_ents'}), + # must write, but may be empty list + must_provide=frozenset({'doc.linked_ents'}), + may_provide=frozenset({}), + collection_contracts=frozenset({ + CollectionContract( + field='doc.linked_ents', + must_provide=frozenset({'cui', 'context_similarity'}), + ), + }), + ) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py new file mode 100644 index 000000000..4fc0fabe3 --- /dev/null +++ b/medcat-v2/medcat/components/contracting.py @@ -0,0 +1,134 @@ +from enum import Enum, auto +from typing import Any, Iterator, Callable +from pydantic import BaseModel +from contextlib import contextmanager + +from medcat.tokenizing.tokens import MutableDocument +from medcat.components.base import BaseComponent + + +class CollectionContract(BaseModel, frozen=True): + """Contract for a collection field — what each item in the collection provides.""" + field: str # e.g. 'ner_ents' + must_provide: frozenset[str] # fields every item must have + may_provide: frozenset[str] = frozenset() + + +class ComponentContract(BaseModel, frozen=True): + needs: frozenset[str] + must_provide: frozenset[str] + may_provide: frozenset[str] = frozenset() + collection_contracts: frozenset[CollectionContract] = frozenset() + + +class ContractViolation(Exception): + pass + + +def iter_relevant_parts(doc: MutableDocument, path: str) -> Iterator[Any]: + if path.startswith("doc."): + yield doc + return + if path.startswith("token."): + yield from doc[:] + else: + raise ValueError(f"Unknown path: {path}") + + +class AccessType(Enum): + READ = auto() + WRITE = auto() + + +@contextmanager +def wrap_relevant_parts( + doc: MutableDocument, + path: str, + access_type: AccessType = AccessType.READ, +): + member_name = path.split(".", 1)[1] + out_list: list[list[Any]] = [] + for part in iter_relevant_parts(doc, path): + # TODO: wrap such that I can tell that it has been accessed + member = getattr(part, member_name) + feedback: list[Any] = [] + setattr(part, WrappedMember(member, feedback, access_type=access_type)) + out_list.append(feedback) + yield feedback + + +def verify_part( + text: str, + doc_getter: Callable[[str], MutableDocument], + component: BaseComponent, + paths: list[str], + access_type: AccessType, + raise_on_violation: bool = True, +): + violations: list[str] = [] + for path in paths: + doc = doc_getter(text) + with wrap_relevant_parts(doc, path) as feedback: + doc = component(doc) + # verify each one accessed + accessed = sum(fb for fb in feedback) + total = len(feedback) + if accessed != total: + violations.append( + f"Component {component.full_name} does not {access_type.name}" + f"{path} ({accessed} / {total} accessed)") + if raise_on_violation: + raise ContractViolation("\n".join(violations)) + return violations + + +def verify_needs( + text: str, + doc_getter: Callable[[str], MutableDocument], + component: BaseComponent, + contract: ComponentContract, + raise_on_violation: bool = True, +) -> list[str]: + return verify_part( + text, doc_getter, component, contract.needs, + AccessType.READ, raise_on_violation=raise_on_violation, + ) + + +def verify_must_provide( + text: str, + doc_getter: Callable[[str], MutableDocument], + component: BaseComponent, + contract: ComponentContract, + raise_on_violation: bool = True, +) -> list[str]: + return verify_part( + text, doc_getter, component, contract.must_provide, + AccessType.WRITE, raise_on_violation=raise_on_violation, + ) + + +def verify_contract( + text: str, + doc_getter: Callable[[str], MutableDocument], + component: BaseComponent, + contract: ComponentContract, + raise_on_violation: bool = True, +) -> list[str]: + """ + Verify a ComponentContract against a document before/after a component ran. + Returns a list of violation messages. Raises ContractViolation if raise_on_violation. + """ + # verify needs are met + violations = verify_needs( + text, doc_getter, component, contract, raise_on_violation=False + ) + # verify mandatory returns are done + violations += verify_must_provide( + text, doc_getter, component, contract, raise_on_violation=False + ) + + if violations and raise_on_violation: + raise ContractViolation("\n".join(violations)) + + return violations From 8eb194496ca9c2bbd471fd8011d7c90f33207508 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 15:39:49 +0100 Subject: [PATCH 03/38] Remove unused import --- medcat-v2/medcat/components/types.py | 1 - 1 file changed, 1 deletion(-) diff --git a/medcat-v2/medcat/components/types.py b/medcat-v2/medcat/components/types.py index 939bafff9..76e1ede74 100644 --- a/medcat-v2/medcat/components/types.py +++ b/medcat-v2/medcat/components/types.py @@ -1,6 +1,5 @@ from typing import Optional, Protocol, Callable, runtime_checkable, Union from typing import Literal -from enum import Enum from abc import ABC, abstractmethod from medcat.utils.registry import Registry, MedCATRegistryException From 3072f9eec2c058d9b9a52ed7f6a756903f95db92 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 15:40:26 +0100 Subject: [PATCH 04/38] Finalise contracting module --- medcat-v2/medcat/components/contracting.py | 83 +++++++++++++++++++--- 1 file changed, 72 insertions(+), 11 deletions(-) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 4fc0fabe3..95a7d8255 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -1,7 +1,8 @@ from enum import Enum, auto from typing import Any, Iterator, Callable from pydantic import BaseModel -from contextlib import contextmanager +from contextlib import contextmanager, ExitStack +from copy import deepcopy from medcat.tokenizing.tokens import MutableDocument from medcat.components.base import BaseComponent @@ -40,6 +41,61 @@ class AccessType(Enum): WRITE = auto() +class WrappedMember: + + def __init__( + self, + part: Any, + member_name: str, + feedback: list[Any], + access_type: AccessType, + ) -> None: + self.part = part + self.member_name = member_name + self.feedback = feedback + self.access_type = access_type + self._oirg_class = type(self.part) + self._install() + + def _install(self): + original_cls = type(self.part) + spy = self # capture for closure + + if self.access_type == AccessType.READ: + + class SpySubclass(original_cls): + def __getattribute__(self, name): + val = super().__getattribute__(name) + if name == spy.member_name: + # save copy + spy.feedback.append(deepcopy(val)) + return val + + elif self.access_type == AccessType.WRITE: + + class SpySubclass(original_cls): + def __setattr__(self, name, value): + if name in spy.member_name: + try: + old = super().__getattribute__(name) + except AttributeError: + old = AttributeError # sentinel: didn't exist yet + # saving copies of state + spy.feedback.append((deepcopy(old), deepcopy(value))) + super().__setattr__(name, value) + + + SpySubclass.__name__ = f"Spy({original_cls.__name__})" + SpySubclass.__qualname__ = SpySubclass.__name__ + self.part.__class__ = SpySubclass + + def __enter__(self): + return self + + def __exit__(self, *_): + self.part.__class__ = self._oirg_class + + @contextmanager def wrap_relevant_parts( doc: MutableDocument, @@ -48,13 +104,16 @@ def wrap_relevant_parts( ): member_name = path.split(".", 1)[1] out_list: list[list[Any]] = [] - for part in iter_relevant_parts(doc, path): - # TODO: wrap such that I can tell that it has been accessed - member = getattr(part, member_name) - feedback: list[Any] = [] - setattr(part, WrappedMember(member, feedback, access_type=access_type)) - out_list.append(feedback) - yield feedback + with ExitStack() as exit_stack: + for part in iter_relevant_parts(doc, path): + feedback: list[Any] = [] + exit_stack.enter_context( + WrappedMember( + part, member_name, + feedback, access_type=access_type) + ) + out_list.append(feedback) + yield feedback def verify_part( @@ -90,7 +149,7 @@ def verify_needs( raise_on_violation: bool = True, ) -> list[str]: return verify_part( - text, doc_getter, component, contract.needs, + text, doc_getter, component, list(contract.needs), AccessType.READ, raise_on_violation=raise_on_violation, ) @@ -103,7 +162,7 @@ def verify_must_provide( raise_on_violation: bool = True, ) -> list[str]: return verify_part( - text, doc_getter, component, contract.must_provide, + text, doc_getter, component, list(contract.must_provide), AccessType.WRITE, raise_on_violation=raise_on_violation, ) @@ -117,7 +176,9 @@ def verify_contract( ) -> list[str]: """ Verify a ComponentContract against a document before/after a component ran. - Returns a list of violation messages. Raises ContractViolation if raise_on_violation. + Returns a list of violation messages. + + Raises ContractViolation if violations found and raise_on_violation. """ # verify needs are met violations = verify_needs( From 7226bf41aba6872c3924bfadfcded89b58974d50 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 15:43:51 +0100 Subject: [PATCH 05/38] Add debug logging regarding violations as they come --- medcat-v2/medcat/components/contracting.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 95a7d8255..81b8477af 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -3,11 +3,14 @@ from pydantic import BaseModel from contextlib import contextmanager, ExitStack from copy import deepcopy +from logging import Logger from medcat.tokenizing.tokens import MutableDocument from medcat.components.base import BaseComponent +logger = Logger(__name__) + class CollectionContract(BaseModel, frozen=True): """Contract for a collection field — what each item in the collection provides.""" field: str # e.g. 'ner_ents' @@ -136,6 +139,12 @@ def verify_part( violations.append( f"Component {component.full_name} does not {access_type.name}" f"{path} ({accessed} / {total} accessed)") + logger.debug( + "Found a virolation in component '%s' for %s at '%s': " + "(%d / %d) accessed with feedback %s", + component.full_name, access_type.name, + path, accessed, total, feedback, + ) if raise_on_violation: raise ContractViolation("\n".join(violations)) return violations From d2d72589bea442aecc53a9531f7b8221bd9bb051 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 15:56:25 +0100 Subject: [PATCH 06/38] Shuffle classes around a little to avoid circular imports --- medcat-v2/medcat/components/base.py | 16 +++++++++++++++- medcat-v2/medcat/components/contracting.py | 16 +--------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/medcat-v2/medcat/components/base.py b/medcat-v2/medcat/components/base.py index ff80e97f2..2d64b945c 100644 --- a/medcat-v2/medcat/components/base.py +++ b/medcat-v2/medcat/components/base.py @@ -2,12 +2,13 @@ from typing_extensions import Self from enum import Enum +from pydantic import BaseModel + from medcat.tokenizing.tokens import MutableDocument from medcat.tokenizing.tokenizers import BaseTokenizer from medcat.cdb import CDB from medcat.vocab import Vocab from medcat.config.config import ComponentConfig -from medcat.components.contracting import ComponentContract, CollectionContract @runtime_checkable @@ -54,6 +55,19 @@ def create_new_component( """ pass +class CollectionContract(BaseModel, frozen=True): + """Contract for a collection field — what each item in the collection provides.""" + field: str # e.g. 'ner_ents' + must_provide: frozenset[str] # fields every item must have + may_provide: frozenset[str] = frozenset() + + +class ComponentContract(BaseModel, frozen=True): + needs: frozenset[str] + must_provide: frozenset[str] + may_provide: frozenset[str] = frozenset() + collection_contracts: frozenset[CollectionContract] = frozenset() + class CoreComponentType(Enum): tagging = ComponentContract( diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 81b8477af..7f563ec68 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -1,29 +1,15 @@ from enum import Enum, auto from typing import Any, Iterator, Callable -from pydantic import BaseModel from contextlib import contextmanager, ExitStack from copy import deepcopy from logging import Logger from medcat.tokenizing.tokens import MutableDocument -from medcat.components.base import BaseComponent +from medcat.components.base import BaseComponent, ComponentContract logger = Logger(__name__) -class CollectionContract(BaseModel, frozen=True): - """Contract for a collection field — what each item in the collection provides.""" - field: str # e.g. 'ner_ents' - must_provide: frozenset[str] # fields every item must have - may_provide: frozenset[str] = frozenset() - - -class ComponentContract(BaseModel, frozen=True): - needs: frozenset[str] - must_provide: frozenset[str] - may_provide: frozenset[str] = frozenset() - collection_contracts: frozenset[CollectionContract] = frozenset() - class ContractViolation(Exception): pass From 99b80132b6e07ab8444dd144d44983dfe110f41b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 16:02:09 +0100 Subject: [PATCH 07/38] Fix minor issue in contracting module --- medcat-v2/medcat/components/contracting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 7f563ec68..f06cbab63 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -119,7 +119,7 @@ def verify_part( with wrap_relevant_parts(doc, path) as feedback: doc = component(doc) # verify each one accessed - accessed = sum(fb for fb in feedback) + accessed = sum(bool(fb) for fb in feedback) total = len(feedback) if accessed != total: violations.append( From 69e52a9d0362c61a66ad33a9d02835f83417ac5c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 16:06:33 +0100 Subject: [PATCH 08/38] Fix minor issue in violation formatting --- medcat-v2/medcat/components/contracting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index f06cbab63..29d1cd146 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -123,7 +123,7 @@ def verify_part( total = len(feedback) if accessed != total: violations.append( - f"Component {component.full_name} does not {access_type.name}" + f"Component {component.full_name} does not {access_type.name} " f"{path} ({accessed} / {total} accessed)") logger.debug( "Found a virolation in component '%s' for %s at '%s': " From 7a2f2a874b42a10e8c1d89eaade5da9de427d090 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 16:34:36 +0100 Subject: [PATCH 09/38] Allow running pipe until specific component --- medcat-v2/medcat/pipeline/pipeline.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/medcat-v2/medcat/pipeline/pipeline.py b/medcat-v2/medcat/pipeline/pipeline.py index 70b9ba11f..0d7241562 100644 --- a/medcat-v2/medcat/pipeline/pipeline.py +++ b/medcat-v2/medcat/pipeline/pipeline.py @@ -324,8 +324,33 @@ def get_doc(self, text: str) -> MutableDocument: Returns: MutableDocument: The resulting document. """ + return self.pipe_until(text, None) + + def pipe_until( + self, + text: str, + comp_type: Optional[CoreComponentType] + ) -> MutableDocument: + """Run the pipe until the specific component (excluded). + + If `comp_type == None` then the entire pipe is run. + Otherwise the pipe is stopped before the specificied component is run. + + Args: + text (str): The text to run over. + comp_type (Optional[CoreComponentType]): The last component to run or None. + + Returns: + MutableDocument: The processed document. + """ doc = self._tokenizer(text) for comp in self._components: + if comp_type and comp.get_type() == comp_type: + logger.info( + "Finishing pipe before %s (%s) as requested", + comp.get_type().name, comp.full_name + ) + return doc logger.info("Running component %s for %d of text (%s)", comp.full_name, len(text), id(text)) doc = comp(doc) @@ -333,6 +358,7 @@ def get_doc(self, text: str) -> MutableDocument: doc = addon(doc) return doc + def entity_from_tokens(self, tokens: list[MutableToken]) -> MutableEntity: """Get the entity from the list of tokens. From 9f285f67c1d72d7a925a229cf3d602091485bce4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 16:43:07 +0100 Subject: [PATCH 10/38] Update copies of state in contracting --- medcat-v2/medcat/components/contracting.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 29d1cd146..20b5c2ed3 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -1,7 +1,6 @@ from enum import Enum, auto from typing import Any, Iterator, Callable from contextlib import contextmanager, ExitStack -from copy import deepcopy from logging import Logger from medcat.tokenizing.tokens import MutableDocument @@ -56,8 +55,8 @@ class SpySubclass(original_cls): def __getattribute__(self, name): val = super().__getattribute__(name) if name == spy.member_name: - # save copy - spy.feedback.append(deepcopy(val)) + # saving str copy of value + spy.feedback.append(str(val)) return val elif self.access_type == AccessType.WRITE: @@ -69,8 +68,8 @@ def __setattr__(self, name, value): old = super().__getattribute__(name) except AttributeError: old = AttributeError # sentinel: didn't exist yet - # saving copies of state - spy.feedback.append((deepcopy(old), deepcopy(value))) + # saving str copies of state + spy.feedback.append((str(old), str(value))) super().__setattr__(name, value) From 1458f95d95aef0b39a494732132b34d6ab693f5b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 18 Jun 2026 15:05:16 +0100 Subject: [PATCH 11/38] Add minimum feedback numbers --- medcat-v2/medcat/components/contracting.py | 27 ++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 20b5c2ed3..e4e132b20 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -101,7 +101,7 @@ def wrap_relevant_parts( feedback, access_type=access_type) ) out_list.append(feedback) - yield feedback + yield out_list def verify_part( @@ -111,13 +111,14 @@ def verify_part( paths: list[str], access_type: AccessType, raise_on_violation: bool = True, + min_feedbacks: int = 0, ): violations: list[str] = [] for path in paths: doc = doc_getter(text) with wrap_relevant_parts(doc, path) as feedback: doc = component(doc) - # verify each one accessed + # verify each one access accessed = sum(bool(fb) for fb in feedback) total = len(feedback) if accessed != total: @@ -130,6 +131,16 @@ def verify_part( component.full_name, access_type.name, path, accessed, total, feedback, ) + elif total < min_feedbacks: + violations.append( + f"Component {component.full_name} did not {access_type.name} " + f"{path} enough ({total} with minimum {min_feedbacks})") + logger.debug( + "Component '%s' did not %s " + "'%s' enough (%d with minimum %d)", + component.full_name, access_type.name, + path, total, min_feedbacks, + ) if raise_on_violation: raise ContractViolation("\n".join(violations)) return violations @@ -141,10 +152,12 @@ def verify_needs( component: BaseComponent, contract: ComponentContract, raise_on_violation: bool = True, + min_feedbacks: int = 0, ) -> list[str]: return verify_part( text, doc_getter, component, list(contract.needs), AccessType.READ, raise_on_violation=raise_on_violation, + min_feedbacks=min_feedbacks, ) @@ -154,10 +167,12 @@ def verify_must_provide( component: BaseComponent, contract: ComponentContract, raise_on_violation: bool = True, + min_feedbacks: int = 0, ) -> list[str]: return verify_part( text, doc_getter, component, list(contract.must_provide), AccessType.WRITE, raise_on_violation=raise_on_violation, + min_feedbacks=min_feedbacks, ) @@ -167,6 +182,8 @@ def verify_contract( component: BaseComponent, contract: ComponentContract, raise_on_violation: bool = True, + min_feedbacks_need: int = 0, + min_feedbacks_provide: int = 0, ) -> list[str]: """ Verify a ComponentContract against a document before/after a component ran. @@ -176,11 +193,13 @@ def verify_contract( """ # verify needs are met violations = verify_needs( - text, doc_getter, component, contract, raise_on_violation=False + text, doc_getter, component, contract, raise_on_violation=False, + min_feedbacks=min_feedbacks_need, ) # verify mandatory returns are done violations += verify_must_provide( - text, doc_getter, component, contract, raise_on_violation=False + text, doc_getter, component, contract, raise_on_violation=False, + min_feedbacks=min_feedbacks_provide, ) if violations and raise_on_violation: From 3925020a181c8b34f99806a4ff78a5f67878e8e9 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Wed, 24 Jun 2026 09:19:12 +0100 Subject: [PATCH 12/38] Add initial contracting tests (WIP) --- .../tests/components/test_contracting.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 medcat-v2/tests/components/test_contracting.py diff --git a/medcat-v2/tests/components/test_contracting.py b/medcat-v2/tests/components/test_contracting.py new file mode 100644 index 000000000..ed176b8ef --- /dev/null +++ b/medcat-v2/tests/components/test_contracting.py @@ -0,0 +1,57 @@ +from unittest import TestCase +from typing import Callable + +from medcat.cat import CAT +from medcat.components import contracting +from medcat.components.base import CoreComponentType +from medcat.components.ner.vocab_based_ner import NER +from medcat.components.linking.context_based_linker import Linker +from medcat.tokenizing.tokens import MutableDocument +from tests import UNPACKED_EXAMPLE_MODEL_PACK_PATH + + + +class TestContractingNer(TestCase): + comp_type = CoreComponentType.ner + comp_cls = NER + args: Callable[[], list] = list + min_feedbacks_need = 0 + min_feedbacks_provide = 1 + text = "John had been diagnosed with acute Kidney Failure the week before." + + @classmethod + def component_prep(cls, text: str) -> MutableDocument: + return cls._model.pipe.pipe_until(text, cls.comp_type) + + @classmethod + def setUpClass(cls) -> None: + cls._model = CAT.load_model_pack(UNPACKED_EXAMPLE_MODEL_PACK_PATH) + cls.tokenizer = cls._model.pipe.tokenizer + cls.cdb = cls._model.cdb + if not cls.args(): + cls.comp = cls.comp_cls(cls.tokenizer, cls.cdb) + else: + cls.comp = cls.comp_cls(*cls.args()) + return super().setUpClass() + + def test_contract_held(self): + violations = contracting.verify_contract( + self.text, self.component_prep, self.comp, self.comp_type.value, + raise_on_violation=False, + min_feedbacks_need=self.min_feedbacks_need, + min_feedbacks_provide=self.min_feedbacks_provide, + ) + self.assertFalse(violations, "Expected not violations") + # self.assertTrue(self.comp(self.component_prep(self.text))) + + +class TestContractingLinker(TestContractingNer): + comp_type = CoreComponentType.linking + comp_cls = Linker + min_feedbacks_need = 1 + + @classmethod + def setUpClass(cls) -> None: + cls.args = lambda: [ + cls.cdb, cls._model.vocab, cls._model.config] + return super().setUpClass() From f2ac5303884187985aa7f82eecd0d2a6e298650c Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 15:55:12 +0100 Subject: [PATCH 13/38] Update contracting to fix token spying --- medcat-v2/medcat/components/contracting.py | 82 +++++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index e4e132b20..7631a4903 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -1,6 +1,7 @@ from enum import Enum, auto -from typing import Any, Iterator, Callable +from typing import Any, Iterator, Callable, Type from contextlib import contextmanager, ExitStack +from collections import defaultdict from logging import Logger from medcat.tokenizing.tokens import MutableDocument @@ -10,6 +11,9 @@ logger = Logger(__name__) +_SENTINEL = object() + + class ContractViolation(Exception): pass @@ -72,7 +76,6 @@ def __setattr__(self, name, value): spy.feedback.append((str(old), str(value))) super().__setattr__(name, value) - SpySubclass.__name__ = f"Spy({original_cls.__name__})" SpySubclass.__qualname__ = SpySubclass.__name__ self.part.__class__ = SpySubclass @@ -84,11 +87,86 @@ def __exit__(self, *_): self.part.__class__ = self._oirg_class +@contextmanager +def spy_token_class( + token_cls: Type, + watched_attr: str, + access_type: AccessType, +): + prev_getattr = token_cls.__dict__.get('__getattribute__', _SENTINEL) + prev_setattr = token_cls.__dict__.get('__setattr__', _SENTINEL) + + per_instance_spied: dict[Any, list[Any]] = defaultdict(list) + + def __getattribute__(self, name: str) -> Any: + val = object.__getattribute__(self, name) + if name == watched_attr: + per_instance_spied[self].append(str(val)) + return val + + def __setattr__(self, name: str, value: Any): + old = object.__getattribute__(self, name) + object.__setattr__(self, name, value) + if name == watched_attr: + per_instance_spied[self].append((str(old), str(value))) + + if access_type == AccessType.READ: + token_cls.__getattribute__ = __getattribute__ + elif access_type == AccessType.WRITE: + token_cls.__setattr__ = __setattr__ + else: + raise ValueError(f"Unknown access type: {access_type}") + try: + yield per_instance_spied + finally: + if prev_getattr is _SENTINEL: + del token_cls.__getattribute__ + else: + token_cls.__getattribute__ = prev_getattr + + if prev_setattr is _SENTINEL: + token_cls.__setattr__ = object.__setattr__ + else: + token_cls.__setattr__ = prev_setattr + + @contextmanager def wrap_relevant_parts( doc: MutableDocument, path: str, access_type: AccessType = AccessType.READ, +): + if path.startswith("doc."): + with wrap_relevant_persistant_parts( + doc, path, access_type + ) as feedbacks: + yield feedbacks + elif path.startswith("token."): + with wrap_relevant_token_cls( + doc, path, access_type + ) as feedbacks: + yield feedbacks + + +@contextmanager +def wrap_relevant_token_cls( + doc: MutableDocument, + path: str, + access_type: AccessType = AccessType.READ, +): + _, attr_name = path.split(".", 1) + tkn_cls = type(next(iter(doc))) + with spy_token_class( + tkn_cls, attr_name, access_type + ) as per_instance_spied: + yield list(per_instance_spied.values()) + + +@contextmanager +def wrap_relevant_persistant_parts( + doc: MutableDocument, + path: str, + access_type: AccessType = AccessType.READ, ): member_name = path.split(".", 1)[1] out_list: list[list[Any]] = [] From 251e5f6cf127338771ef97ce7fc010994ea76529 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:02:55 +0100 Subject: [PATCH 14/38] Move some contracting stuff to the a separate utility module --- medcat-v2/medcat/components/contracting.py | 178 +---------------- .../medcat/components/contracting_utils.py | 180 ++++++++++++++++++ 2 files changed, 183 insertions(+), 175 deletions(-) create mode 100644 medcat-v2/medcat/components/contracting_utils.py diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 7631a4903..2074f6756 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -1,187 +1,15 @@ -from enum import Enum, auto -from typing import Any, Iterator, Callable, Type -from contextlib import contextmanager, ExitStack -from collections import defaultdict +from typing import Callable from logging import Logger from medcat.tokenizing.tokens import MutableDocument from medcat.components.base import BaseComponent, ComponentContract +from medcat.components.contracting_utils import ( + AccessType, wrap_relevant_parts, ContractViolation) logger = Logger(__name__) -_SENTINEL = object() - - -class ContractViolation(Exception): - pass - - -def iter_relevant_parts(doc: MutableDocument, path: str) -> Iterator[Any]: - if path.startswith("doc."): - yield doc - return - if path.startswith("token."): - yield from doc[:] - else: - raise ValueError(f"Unknown path: {path}") - - -class AccessType(Enum): - READ = auto() - WRITE = auto() - - -class WrappedMember: - - def __init__( - self, - part: Any, - member_name: str, - feedback: list[Any], - access_type: AccessType, - ) -> None: - self.part = part - self.member_name = member_name - self.feedback = feedback - self.access_type = access_type - self._oirg_class = type(self.part) - self._install() - - def _install(self): - original_cls = type(self.part) - spy = self # capture for closure - - if self.access_type == AccessType.READ: - - class SpySubclass(original_cls): - def __getattribute__(self, name): - val = super().__getattribute__(name) - if name == spy.member_name: - # saving str copy of value - spy.feedback.append(str(val)) - return val - - elif self.access_type == AccessType.WRITE: - - class SpySubclass(original_cls): - def __setattr__(self, name, value): - if name in spy.member_name: - try: - old = super().__getattribute__(name) - except AttributeError: - old = AttributeError # sentinel: didn't exist yet - # saving str copies of state - spy.feedback.append((str(old), str(value))) - super().__setattr__(name, value) - - SpySubclass.__name__ = f"Spy({original_cls.__name__})" - SpySubclass.__qualname__ = SpySubclass.__name__ - self.part.__class__ = SpySubclass - - def __enter__(self): - return self - - def __exit__(self, *_): - self.part.__class__ = self._oirg_class - - -@contextmanager -def spy_token_class( - token_cls: Type, - watched_attr: str, - access_type: AccessType, -): - prev_getattr = token_cls.__dict__.get('__getattribute__', _SENTINEL) - prev_setattr = token_cls.__dict__.get('__setattr__', _SENTINEL) - - per_instance_spied: dict[Any, list[Any]] = defaultdict(list) - - def __getattribute__(self, name: str) -> Any: - val = object.__getattribute__(self, name) - if name == watched_attr: - per_instance_spied[self].append(str(val)) - return val - - def __setattr__(self, name: str, value: Any): - old = object.__getattribute__(self, name) - object.__setattr__(self, name, value) - if name == watched_attr: - per_instance_spied[self].append((str(old), str(value))) - - if access_type == AccessType.READ: - token_cls.__getattribute__ = __getattribute__ - elif access_type == AccessType.WRITE: - token_cls.__setattr__ = __setattr__ - else: - raise ValueError(f"Unknown access type: {access_type}") - try: - yield per_instance_spied - finally: - if prev_getattr is _SENTINEL: - del token_cls.__getattribute__ - else: - token_cls.__getattribute__ = prev_getattr - - if prev_setattr is _SENTINEL: - token_cls.__setattr__ = object.__setattr__ - else: - token_cls.__setattr__ = prev_setattr - - -@contextmanager -def wrap_relevant_parts( - doc: MutableDocument, - path: str, - access_type: AccessType = AccessType.READ, -): - if path.startswith("doc."): - with wrap_relevant_persistant_parts( - doc, path, access_type - ) as feedbacks: - yield feedbacks - elif path.startswith("token."): - with wrap_relevant_token_cls( - doc, path, access_type - ) as feedbacks: - yield feedbacks - - -@contextmanager -def wrap_relevant_token_cls( - doc: MutableDocument, - path: str, - access_type: AccessType = AccessType.READ, -): - _, attr_name = path.split(".", 1) - tkn_cls = type(next(iter(doc))) - with spy_token_class( - tkn_cls, attr_name, access_type - ) as per_instance_spied: - yield list(per_instance_spied.values()) - - -@contextmanager -def wrap_relevant_persistant_parts( - doc: MutableDocument, - path: str, - access_type: AccessType = AccessType.READ, -): - member_name = path.split(".", 1)[1] - out_list: list[list[Any]] = [] - with ExitStack() as exit_stack: - for part in iter_relevant_parts(doc, path): - feedback: list[Any] = [] - exit_stack.enter_context( - WrappedMember( - part, member_name, - feedback, access_type=access_type) - ) - out_list.append(feedback) - yield out_list - - def verify_part( text: str, doc_getter: Callable[[str], MutableDocument], diff --git a/medcat-v2/medcat/components/contracting_utils.py b/medcat-v2/medcat/components/contracting_utils.py new file mode 100644 index 000000000..b5366fe0a --- /dev/null +++ b/medcat-v2/medcat/components/contracting_utils.py @@ -0,0 +1,180 @@ +from typing import Any, Iterator, Type +from contextlib import contextmanager, ExitStack +from collections import defaultdict +from logging import Logger +from enum import Enum, auto + +from medcat.tokenizing.tokens import MutableDocument + + +logger = Logger(__name__) + +_SENTINEL = object() + + +class ContractViolation(Exception): + pass + + +class AccessType(Enum): + READ = auto() + WRITE = auto() + + +def iter_relevant_parts(doc: MutableDocument, path: str) -> Iterator[Any]: + if path.startswith("doc."): + yield doc + return + if path.startswith("token."): + yield from doc[:] + else: + raise ValueError(f"Unknown path: {path}") + + +class WrappedMember: + + def __init__( + self, + part: Any, + member_name: str, + feedback: list[Any], + access_type: AccessType, + ) -> None: + self.part = part + self.member_name = member_name + self.feedback = feedback + self.access_type = access_type + self._oirg_class = type(self.part) + self._install() + + def _install(self): + original_cls = type(self.part) + spy = self # capture for closure + + if self.access_type == AccessType.READ: + + class SpySubclass(original_cls): + def __getattribute__(self, name): + val = super().__getattribute__(name) + if name == spy.member_name: + # saving str copy of value + spy.feedback.append(str(val)) + return val + + elif self.access_type == AccessType.WRITE: + + class SpySubclass(original_cls): + def __setattr__(self, name, value): + if name in spy.member_name: + try: + old = super().__getattribute__(name) + except AttributeError: + old = AttributeError # sentinel: didn't exist yet + # saving str copies of state + spy.feedback.append((str(old), str(value))) + super().__setattr__(name, value) + + SpySubclass.__name__ = f"Spy({original_cls.__name__})" + SpySubclass.__qualname__ = SpySubclass.__name__ + self.part.__class__ = SpySubclass + + def __enter__(self): + return self + + def __exit__(self, *_): + self.part.__class__ = self._oirg_class + + +@contextmanager +def spy_token_class( + token_cls: Type, + watched_attr: str, + access_type: AccessType, +): + prev_getattr = token_cls.__dict__.get('__getattribute__', _SENTINEL) + prev_setattr = token_cls.__dict__.get('__setattr__', _SENTINEL) + + per_instance_spied: dict[Any, list[Any]] = defaultdict(list) + + def __getattribute__(self, name: str) -> Any: + val = object.__getattribute__(self, name) + if name == watched_attr: + per_instance_spied[self].append(str(val)) + return val + + def __setattr__(self, name: str, value: Any): + old = object.__getattribute__(self, name) + object.__setattr__(self, name, value) + if name == watched_attr: + per_instance_spied[self].append((str(old), str(value))) + + if access_type == AccessType.READ: + token_cls.__getattribute__ = __getattribute__ + elif access_type == AccessType.WRITE: + token_cls.__setattr__ = __setattr__ + else: + raise ValueError(f"Unknown access type: {access_type}") + try: + yield per_instance_spied + finally: + if prev_getattr is _SENTINEL: + del token_cls.__getattribute__ + else: + token_cls.__getattribute__ = prev_getattr + + if prev_setattr is _SENTINEL: + token_cls.__setattr__ = object.__setattr__ + else: + token_cls.__setattr__ = prev_setattr + + +@contextmanager +def wrap_relevant_parts( + doc: MutableDocument, + path: str, + access_type: AccessType = AccessType.READ, +): + if path.startswith("doc."): + with wrap_relevant_persistant_parts( + doc, path, access_type + ) as feedbacks: + yield feedbacks + elif path.startswith("token."): + with wrap_relevant_token_cls( + doc, path, access_type + ) as feedbacks: + yield feedbacks + + +@contextmanager +def wrap_relevant_token_cls( + doc: MutableDocument, + path: str, + access_type: AccessType = AccessType.READ, +): + _, attr_name = path.split(".", 1) + tkn_cls = type(next(iter(doc))) + with spy_token_class( + tkn_cls, attr_name, access_type + ) as per_instance_spied: + yield list(per_instance_spied.values()) + + +@contextmanager +def wrap_relevant_persistant_parts( + doc: MutableDocument, + path: str, + access_type: AccessType = AccessType.READ, +): + member_name = path.split(".", 1)[1] + out_list: list[list[Any]] = [] + with ExitStack() as exit_stack: + for part in iter_relevant_parts(doc, path): + feedback: list[Any] = [] + exit_stack.enter_context( + WrappedMember( + part, member_name, + feedback, access_type=access_type) + ) + out_list.append(feedback) + yield out_list From 5aee12d00249a2d028befd1fc6d93bc86864fba5 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:12:25 +0100 Subject: [PATCH 15/38] Add doc strings to various methods --- medcat-v2/medcat/components/contracting.py | 51 +++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 2074f6756..35c3c17e8 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -18,7 +18,26 @@ def verify_part( access_type: AccessType, raise_on_violation: bool = True, min_feedbacks: int = 0, -): +) -> list[str]: + """Verify the parts for this text. + + Args: + text (str): The text to use. + doc_getter (Callable[[str], MutableDocument]): The document getter. + component (BaseComponent): The component to check. + paths (list[str]): The paths to check. + access_type (AccessType): The type of access to check. + raise_on_violation (bool): Whether to raise on a violation. + Defaults to True. + min_feedbacks (int): The minimum number of feedbacks expected. + Defaults to 0. + + Raises: + ContractViolation: If there are violations and instructed to raise. + + Returns: + list[str]: The list of violations, if any. + """ violations: list[str] = [] for path in paths: doc = doc_getter(text) @@ -60,6 +79,21 @@ def verify_needs( raise_on_violation: bool = True, min_feedbacks: int = 0, ) -> list[str]: + """Verify the needs portion of a contract. + + Args: + text (str): The text to use. + doc_getter (Callable[[str], MutableDocument]): The document getter. + component (BaseComponent): The component to check. + contract (ComponentContract): The contract to check. + raise_on_violation (bool): Whether to raise on violations. + Defaults to True. + min_feedbacks (int,): The minimum number of feedbacks expected. + Defaults to 0. + + Returns: + list[str]: The list of violations, if any. + """ return verify_part( text, doc_getter, component, list(contract.needs), AccessType.READ, raise_on_violation=raise_on_violation, @@ -75,6 +109,21 @@ def verify_must_provide( raise_on_violation: bool = True, min_feedbacks: int = 0, ) -> list[str]: + """Verify the must-provide portion of a contract. + + Args: + text (str): The text to use. + doc_getter (Callable[[str], MutableDocument]): The document getter. + component (BaseComponent): The component to check. + contract (ComponentContract): The contract to check. + raise_on_violation (bool): Whether to raise on violations. + Defaults to True. + min_feedbacks (int,): The minimum number of feedbacks expected. + Defaults to 0. + + Returns: + list[str]: The list of violations, if any. + """ return verify_part( text, doc_getter, component, list(contract.must_provide), AccessType.WRITE, raise_on_violation=raise_on_violation, From 639d998b4533ea62b86630b9840c0f44bac42725 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:14:43 +0100 Subject: [PATCH 16/38] Add type ignoring comments where needed --- medcat-v2/medcat/components/contracting_utils.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/medcat-v2/medcat/components/contracting_utils.py b/medcat-v2/medcat/components/contracting_utils.py index b5366fe0a..d511671a7 100644 --- a/medcat-v2/medcat/components/contracting_utils.py +++ b/medcat-v2/medcat/components/contracting_utils.py @@ -109,9 +109,11 @@ def __setattr__(self, name: str, value: Any): per_instance_spied[self].append((str(old), str(value))) if access_type == AccessType.READ: - token_cls.__getattribute__ = __getattribute__ + # NOTE: this should be fine, but mypy complains due to self + token_cls.__getattribute__ = __getattribute__ # type: ignore elif access_type == AccessType.WRITE: - token_cls.__setattr__ = __setattr__ + # NOTE: this should be fine, but mypy complains due to self + token_cls.__setattr__ = __setattr__ # type: ignore else: raise ValueError(f"Unknown access type: {access_type}") try: @@ -123,7 +125,8 @@ def __setattr__(self, name: str, value: Any): token_cls.__getattribute__ = prev_getattr if prev_setattr is _SENTINEL: - token_cls.__setattr__ = object.__setattr__ + # NOTE: this should be fine, but mypy complains due to self + token_cls.__setattr__ = object.__setattr__ # type: ignore else: token_cls.__setattr__ = prev_setattr From da62a7d7c9dd9db32348849e317eb30f35ace1e7 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:15:34 +0100 Subject: [PATCH 17/38] Add initial contracting tests --- medcat-v2/tests/components/test_contracting.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/medcat-v2/tests/components/test_contracting.py b/medcat-v2/tests/components/test_contracting.py index ed176b8ef..7ca7a458f 100644 --- a/medcat-v2/tests/components/test_contracting.py +++ b/medcat-v2/tests/components/test_contracting.py @@ -10,7 +10,6 @@ from tests import UNPACKED_EXAMPLE_MODEL_PACK_PATH - class TestContractingNer(TestCase): comp_type = CoreComponentType.ner comp_cls = NER @@ -42,7 +41,6 @@ def test_contract_held(self): min_feedbacks_provide=self.min_feedbacks_provide, ) self.assertFalse(violations, "Expected not violations") - # self.assertTrue(self.comp(self.component_prep(self.text))) class TestContractingLinker(TestContractingNer): From fff249a065ced3644feb0e422030bb9a3d7047cc Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:15:55 +0100 Subject: [PATCH 18/38] Fix typo --- medcat-v2/tests/components/test_contracting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-v2/tests/components/test_contracting.py b/medcat-v2/tests/components/test_contracting.py index 7ca7a458f..71cce6ed2 100644 --- a/medcat-v2/tests/components/test_contracting.py +++ b/medcat-v2/tests/components/test_contracting.py @@ -40,7 +40,7 @@ def test_contract_held(self): min_feedbacks_need=self.min_feedbacks_need, min_feedbacks_provide=self.min_feedbacks_provide, ) - self.assertFalse(violations, "Expected not violations") + self.assertFalse(violations, "Expected no violations") class TestContractingLinker(TestContractingNer): From b58e52bdeb89eec8b8d2baff6a8a516cec0bb9a6 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:28:01 +0100 Subject: [PATCH 19/38] Yield a live container rather than converting where appropriate --- medcat-v2/medcat/components/contracting_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-v2/medcat/components/contracting_utils.py b/medcat-v2/medcat/components/contracting_utils.py index d511671a7..cde9ed741 100644 --- a/medcat-v2/medcat/components/contracting_utils.py +++ b/medcat-v2/medcat/components/contracting_utils.py @@ -160,7 +160,7 @@ def wrap_relevant_token_cls( with spy_token_class( tkn_cls, attr_name, access_type ) as per_instance_spied: - yield list(per_instance_spied.values()) + yield per_instance_spied.values() @contextmanager From 13108123b6f7809f2c6fcdcec4b54fca87552bea Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:29:50 +0100 Subject: [PATCH 20/38] Amend tests to assert non-trivial number of needs --- medcat-v2/tests/components/test_contracting.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/medcat-v2/tests/components/test_contracting.py b/medcat-v2/tests/components/test_contracting.py index 71cce6ed2..1ff4155b5 100644 --- a/medcat-v2/tests/components/test_contracting.py +++ b/medcat-v2/tests/components/test_contracting.py @@ -14,9 +14,11 @@ class TestContractingNer(TestCase): comp_type = CoreComponentType.ner comp_cls = NER args: Callable[[], list] = list - min_feedbacks_need = 0 + min_feedbacks_need = 23 min_feedbacks_provide = 1 - text = "John had been diagnosed with acute Kidney Failure the week before." + text = """ + John had been diagnosed with acute Kidney - Failure the week before. + """ @classmethod def component_prep(cls, text: str) -> MutableDocument: @@ -40,6 +42,8 @@ def test_contract_held(self): min_feedbacks_need=self.min_feedbacks_need, min_feedbacks_provide=self.min_feedbacks_provide, ) + doc = self.component_prep(self.text) + print("PIPE man", doc, "->", [tkn for tkn in doc if tkn.to_skip]) self.assertFalse(violations, "Expected no violations") From 8ee0176bdc11e203ed0526fd1d07ec5223979ad7 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:35:49 +0100 Subject: [PATCH 21/38] Add comment to tests --- medcat-v2/tests/components/test_contracting.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/medcat-v2/tests/components/test_contracting.py b/medcat-v2/tests/components/test_contracting.py index 1ff4155b5..871f15456 100644 --- a/medcat-v2/tests/components/test_contracting.py +++ b/medcat-v2/tests/components/test_contracting.py @@ -14,7 +14,16 @@ class TestContractingNer(TestCase): comp_type = CoreComponentType.ner comp_cls = NER args: Callable[[], list] = list + # NOTE: these depend on the text and component + # e.g for NER, this is called once per token to check + # if they're skipped + # e.g for linker, this counts the number of time + # doc.ner_ents is read min_feedbacks_need = 23 + # NOTE: e.g for NER, this counts the setting of detected_name + # for each token in doc.ner_ents + # e.g for linker, this counts the setting of + # cui and context_similarity for each token in doc.linked_ents min_feedbacks_provide = 1 text = """ John had been diagnosed with acute Kidney - Failure the week before. From 0ed14902d0a8bc9e1e0b26187de854f9d39074f5 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 16:45:56 +0100 Subject: [PATCH 22/38] Infer number of min needed feedback automatically --- .../tests/components/test_contracting.py | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/medcat-v2/tests/components/test_contracting.py b/medcat-v2/tests/components/test_contracting.py index 871f15456..27b6cafed 100644 --- a/medcat-v2/tests/components/test_contracting.py +++ b/medcat-v2/tests/components/test_contracting.py @@ -14,17 +14,6 @@ class TestContractingNer(TestCase): comp_type = CoreComponentType.ner comp_cls = NER args: Callable[[], list] = list - # NOTE: these depend on the text and component - # e.g for NER, this is called once per token to check - # if they're skipped - # e.g for linker, this counts the number of time - # doc.ner_ents is read - min_feedbacks_need = 23 - # NOTE: e.g for NER, this counts the setting of detected_name - # for each token in doc.ner_ents - # e.g for linker, this counts the setting of - # cui and context_similarity for each token in doc.linked_ents - min_feedbacks_provide = 1 text = """ John had been diagnosed with acute Kidney - Failure the week before. """ @@ -45,21 +34,25 @@ def setUpClass(cls) -> None: return super().setUpClass() def test_contract_held(self): + min_feedbacks_need = ( + len(list(self.component_prep(self.text))) + if self.comp_type is CoreComponentType.ner else + 1 + ) + # for NER / linker it's just setting once + min_feedbacks_provide = 1 violations = contracting.verify_contract( self.text, self.component_prep, self.comp, self.comp_type.value, raise_on_violation=False, - min_feedbacks_need=self.min_feedbacks_need, - min_feedbacks_provide=self.min_feedbacks_provide, + min_feedbacks_need=min_feedbacks_need, + min_feedbacks_provide=min_feedbacks_provide, ) - doc = self.component_prep(self.text) - print("PIPE man", doc, "->", [tkn for tkn in doc if tkn.to_skip]) self.assertFalse(violations, "Expected no violations") class TestContractingLinker(TestContractingNer): comp_type = CoreComponentType.linking comp_cls = Linker - min_feedbacks_need = 1 @classmethod def setUpClass(cls) -> None: From 990c0b8229bf40efd69a5eba4cc50c36cb5b09f2 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 17:08:17 +0100 Subject: [PATCH 23/38] Add a contracting testing module --- .../medcat/components/contracting_testing.py | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 medcat-v2/medcat/components/contracting_testing.py diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py new file mode 100644 index 000000000..6f2f8ea3c --- /dev/null +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -0,0 +1,84 @@ +from typing import Optional + +from medcat.cat import CAT +from medcat.tokenizing.tokens import MutableDocument +from medcat.components.base import CoreComponentType +from medcat.components.types import CoreComponent +from medcat.components.contracting import verify_contract + + +_DEFAULT_CONTRACT_TEXT = """ +John had been diagnosed with acute Kidney - Failure the week before. +""" + + +class ContractViolationError(ValueError): + pass + + +def assert_single_component_holds( + model: CAT, + component: CoreComponent, + text: str = _DEFAULT_CONTRACT_TEXT, +): + """Assert a specific component's contract holds. + + Args: + model (CAT): The model with the specific component. + component (CoreComponent): The component under test. + text (str): The text to use for the check. + Defaults to _DEFAULT_CONTRACT_TEXT. + """ + component_type = component.get_type() + + def prep(t: str) -> MutableDocument: + return model.pipe.pipe_until(t, component_type) + + contract = component_type.value + min_feedbacks_need = ( + len(list(prep(text))) if component_type is CoreComponentType.ner else 1 + ) + violations = verify_contract( + text, prep, component, contract, + raise_on_violation=False, + min_feedbacks_need=min_feedbacks_need, + min_feedbacks_provide=1, + ) + if violations: + raise ContractViolationError(component_type, violations) + + +def assert_component_contracts( + model: CAT, + text: str = _DEFAULT_CONTRACT_TEXT, + to_check: Optional[list[CoreComponentType]] = None +): + """Verify that all components upholds its MedCAT contract. + + Intended for use in tests by external component implementers. + Raises ContractViolationError if the contract is not upheld. + + Example: + + def test_my_ner_contract(self): + cat = create_model_with_my_component() + assert_component_contract(cat) + + Args: + model (CAT): The model pack to use. This needs to refer to a model that + is able to NER and link at least 1 entity in the provided text. + This model needs to already have the relevant component(s) to be + checked. + to_check (Optional[list[CoreComponentType]]): The core component types + to check. Defaults to NER and linking. + text (str): The text to use for the check. + Defaults to _DEFAULT_CONTRACT_TEXT. + + Raises: + ContractViolationError: If there are any violations found. + """ + if to_check is None: + to_check = [CoreComponentType.ner, CoreComponentType.linking] + for cct in to_check: + cur_comp = model.pipe.get_component(cct) + assert_single_component_holds(model, cur_comp, text) From 727406a18d12af41dc05ba237786e8f1b9ed5060 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 17:10:03 +0100 Subject: [PATCH 24/38] Specify default value in module rather than within method --- medcat-v2/medcat/components/contracting_testing.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index 6f2f8ea3c..eb7107229 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -10,6 +10,8 @@ _DEFAULT_CONTRACT_TEXT = """ John had been diagnosed with acute Kidney - Failure the week before. """ +_DEFAULT_COMP_TYPES_TO_CHECK = [ + CoreComponentType.ner, CoreComponentType.linking] class ContractViolationError(ValueError): @@ -78,7 +80,7 @@ def test_my_ner_contract(self): ContractViolationError: If there are any violations found. """ if to_check is None: - to_check = [CoreComponentType.ner, CoreComponentType.linking] + to_check = _DEFAULT_COMP_TYPES_TO_CHECK for cct in to_check: cur_comp = model.pipe.get_component(cct) assert_single_component_holds(model, cur_comp, text) From f60ff01ea47392ba5c5da934e1582c400fdcfb74 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 17:14:58 +0100 Subject: [PATCH 25/38] Avoid testing min feedbacks 0 (i.e if nothing to tokenize) --- medcat-v2/medcat/components/contracting_testing.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index eb7107229..76c11bb46 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -40,6 +40,11 @@ def prep(t: str) -> MutableDocument: min_feedbacks_need = ( len(list(prep(text))) if component_type is CoreComponentType.ner else 1 ) + if not min_feedbacks_need: + # NOTE: this would normally happen with NER if/when there's no tokens + raise ContractViolationError( + "Cannot check for feedback needs if minimum is 0 " + f"for {component.full_name}") violations = verify_contract( text, prep, component, contract, raise_on_violation=False, From 6d8507b6be8d8f6b09d1e9148c23ed6f181838c6 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 17:15:51 +0100 Subject: [PATCH 26/38] Simplify contracting tests --- .../tests/components/test_contracting.py | 54 ++----------------- 1 file changed, 4 insertions(+), 50 deletions(-) diff --git a/medcat-v2/tests/components/test_contracting.py b/medcat-v2/tests/components/test_contracting.py index 27b6cafed..afeefa883 100644 --- a/medcat-v2/tests/components/test_contracting.py +++ b/medcat-v2/tests/components/test_contracting.py @@ -1,61 +1,15 @@ from unittest import TestCase -from typing import Callable from medcat.cat import CAT -from medcat.components import contracting -from medcat.components.base import CoreComponentType -from medcat.components.ner.vocab_based_ner import NER -from medcat.components.linking.context_based_linker import Linker -from medcat.tokenizing.tokens import MutableDocument +from medcat.components import contracting_testing from tests import UNPACKED_EXAMPLE_MODEL_PACK_PATH -class TestContractingNer(TestCase): - comp_type = CoreComponentType.ner - comp_cls = NER - args: Callable[[], list] = list - text = """ - John had been diagnosed with acute Kidney - Failure the week before. - """ - - @classmethod - def component_prep(cls, text: str) -> MutableDocument: - return cls._model.pipe.pipe_until(text, cls.comp_type) +class TestContractingForModel(TestCase): @classmethod def setUpClass(cls) -> None: cls._model = CAT.load_model_pack(UNPACKED_EXAMPLE_MODEL_PACK_PATH) - cls.tokenizer = cls._model.pipe.tokenizer - cls.cdb = cls._model.cdb - if not cls.args(): - cls.comp = cls.comp_cls(cls.tokenizer, cls.cdb) - else: - cls.comp = cls.comp_cls(*cls.args()) - return super().setUpClass() - - def test_contract_held(self): - min_feedbacks_need = ( - len(list(self.component_prep(self.text))) - if self.comp_type is CoreComponentType.ner else - 1 - ) - # for NER / linker it's just setting once - min_feedbacks_provide = 1 - violations = contracting.verify_contract( - self.text, self.component_prep, self.comp, self.comp_type.value, - raise_on_violation=False, - min_feedbacks_need=min_feedbacks_need, - min_feedbacks_provide=min_feedbacks_provide, - ) - self.assertFalse(violations, "Expected no violations") - -class TestContractingLinker(TestContractingNer): - comp_type = CoreComponentType.linking - comp_cls = Linker - - @classmethod - def setUpClass(cls) -> None: - cls.args = lambda: [ - cls.cdb, cls._model.vocab, cls._model.config] - return super().setUpClass() + def test_contracts_held(self): + contracting_testing.assert_component_contracts(self._model) From f6566d318ba35d7c57deaa1f5d179a5eabfdd66d Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 17:20:41 +0100 Subject: [PATCH 27/38] Add extra test for individual components --- medcat-v2/tests/components/test_contracting.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/medcat-v2/tests/components/test_contracting.py b/medcat-v2/tests/components/test_contracting.py index afeefa883..225779a30 100644 --- a/medcat-v2/tests/components/test_contracting.py +++ b/medcat-v2/tests/components/test_contracting.py @@ -2,6 +2,7 @@ from medcat.cat import CAT from medcat.components import contracting_testing +from medcat.components.base import CoreComponentType from tests import UNPACKED_EXAMPLE_MODEL_PACK_PATH @@ -11,5 +12,10 @@ class TestContractingForModel(TestCase): def setUpClass(cls) -> None: cls._model = CAT.load_model_pack(UNPACKED_EXAMPLE_MODEL_PACK_PATH) - def test_contracts_held(self): + def test_all_contracts_hold(self): contracting_testing.assert_component_contracts(self._model) + + def test_individual_contracts_hold(self): + for ct in [CoreComponentType.ner, CoreComponentType.linking]: + comp = self._model.pipe.get_component(ct) + contracting_testing.assert_single_component_holds(self._model, comp) From 6f15e242099180933e8a837952dbc8cb37551257 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 17:24:49 +0100 Subject: [PATCH 28/38] Add example to single-component check --- medcat-v2/medcat/components/contracting_testing.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index 76c11bb46..949e71c03 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -25,6 +25,13 @@ def assert_single_component_holds( ): """Assert a specific component's contract holds. + Example: + + def test_my_ner_contract(self): + cat = create_model_with_my_ner() + my_ner = cat.pipe.get_component(CoreComponentType.ner) + assert_single_component_holds(cat, my_ner) + Args: model (CAT): The model with the specific component. component (CoreComponent): The component under test. From 70ee5d4dad5a762101f4d23ac4de7cfbaebbd7a1 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 17:26:43 +0100 Subject: [PATCH 29/38] Make a better exception be raised if/when contracts do not hold --- medcat-v2/medcat/components/contracting_testing.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index 949e71c03..f960115a0 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -15,7 +15,13 @@ class ContractViolationError(ValueError): - pass + + def __init__(self, component_type: CoreComponentType, violations: list): + self.component_type = component_type + self.violations = violations + super().__init__( + f"Contract violations for {component_type.name}: {violations}" + ) def assert_single_component_holds( From 0523f0b735399e00847b76b5b5257ef93f8bb31e Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 18:23:45 +0100 Subject: [PATCH 30/38] Fix exception creation --- medcat-v2/medcat/components/contracting_testing.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index f960115a0..de5baa8fd 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -56,8 +56,9 @@ def prep(t: str) -> MutableDocument: if not min_feedbacks_need: # NOTE: this would normally happen with NER if/when there's no tokens raise ContractViolationError( - "Cannot check for feedback needs if minimum is 0 " - f"for {component.full_name}") + component_type, + ["Cannot check for feedback needs if minimum is 0 " + f"for {component.full_name}", ]) violations = verify_contract( text, prep, component, contract, raise_on_violation=False, From 23ea94e830ca2311229f046a02f32a9233ccf619 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 18:54:16 +0100 Subject: [PATCH 31/38] Add collection contracting tp contract checks --- medcat-v2/medcat/components/contracting.py | 71 ++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index 35c3c17e8..a44b5f0fa 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -131,6 +131,71 @@ def verify_must_provide( ) +def verify_collections_contracts( + text: str, + doc_getter: Callable[[str], MutableDocument], + component: BaseComponent, + contract: ComponentContract, + raise_on_violation: bool = True, + min_feedbacks: int = 0, +) -> list[str]: + """Verify the collection contracts portion of a contract. + + Args: + text (str): The text to use. + doc_getter (Callable[[str], MutableDocument]): The document getter. + component (BaseComponent): The component to check. + contract (ComponentContract): The contract to check. + raise_on_violation (bool): Whether to raise on violations. + Defaults to True. + min_feedbacks (int): The minimum number of feedbacks expected. + Defaults to 0. + + Returns: + list[str]: The list of violations, if any. + """ + violations: list[str] = [] + if not contract.collection_contracts: + return violations + doc = doc_getter(text) + doc = component(doc) + for cc in contract.collection_contracts: + if not cc.field.startswith("doc."): + violations.append( + f"Collection contract field '{cc.field}' is not a doc-level " + f"field — only doc.* fields are currently supported") + continue + attr = cc.field.split(".", 1)[1] + try: + collection = getattr(doc, attr) + except AttributeError: + violations.append( + f"Component {component.full_name} did not provide " + f"collection '{cc.field}' at all") + continue + items = list(collection) + if len(items) < min_feedbacks: + violations.append( + f"Collection '{cc.field}' has too few items " + f"({len(items)} with minimum {min_feedbacks})") + for i, item in enumerate(items): + for field in cc.must_provide: + try: + val = getattr(item, field) + except AttributeError: + violations.append( + f"Item {i} in '{cc.field}' is missing " + f"required field '{field}'") + continue + if not val: + violations.append( + f"Item {i} in '{cc.field}' has falsy value " + f"for required field '{field}' (got {val!r})") + if violations and raise_on_violation: + raise ContractViolation("\n".join(violations)) + return violations + + def verify_contract( text: str, doc_getter: Callable[[str], MutableDocument], @@ -139,6 +204,7 @@ def verify_contract( raise_on_violation: bool = True, min_feedbacks_need: int = 0, min_feedbacks_provide: int = 0, + min_feedbacks_contracts: int = 0, ) -> list[str]: """ Verify a ComponentContract against a document before/after a component ran. @@ -156,6 +222,11 @@ def verify_contract( text, doc_getter, component, contract, raise_on_violation=False, min_feedbacks=min_feedbacks_provide, ) + # verify collections contracts + violations += verify_collections_contracts( + text, doc_getter, component, contract, raise_on_violation=False, + min_feedbacks=min_feedbacks_contracts, + ) if violations and raise_on_violation: raise ContractViolation("\n".join(violations)) From 334ec1fbfd95a1bb2e24606bea15bc7c2aaa3b14 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 18:54:34 +0100 Subject: [PATCH 32/38] Expect at least one entity in output --- medcat-v2/medcat/components/contracting_testing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index de5baa8fd..3e6179b3c 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -64,6 +64,7 @@ def prep(t: str) -> MutableDocument: raise_on_violation=False, min_feedbacks_need=min_feedbacks_need, min_feedbacks_provide=1, + min_feedbacks_contracts=1, ) if violations: raise ContractViolationError(component_type, violations) From 47aab1bd38115b1daa222b22a3a165d95394d092 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 18:55:40 +0100 Subject: [PATCH 33/38] Add a few comments to testing utils --- medcat-v2/medcat/components/contracting_testing.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index 3e6179b3c..614c1b574 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -63,7 +63,10 @@ def prep(t: str) -> MutableDocument: text, prep, component, contract, raise_on_violation=False, min_feedbacks_need=min_feedbacks_need, + # NOTE: this means that the collection (ner_ents or linked_ents) is + # written to at least once min_feedbacks_provide=1, + # NOTE: this means we expect at least 1 entity in output min_feedbacks_contracts=1, ) if violations: From 8671d2448489c247e5a4455f4d779ff51370240e Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 18:56:02 +0100 Subject: [PATCH 34/38] Add a few tests for the contracting testing module --- .../components/test_contracting_testing.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 medcat-v2/tests/components/test_contracting_testing.py diff --git a/medcat-v2/tests/components/test_contracting_testing.py b/medcat-v2/tests/components/test_contracting_testing.py new file mode 100644 index 000000000..df96ae019 --- /dev/null +++ b/medcat-v2/tests/components/test_contracting_testing.py @@ -0,0 +1,39 @@ +from unittest import TestCase +from contextlib import contextmanager + +from medcat.cat import CAT +from medcat.components import contracting_testing +from medcat.utils.cdb_state import captured_state_cdb +from tests import UNPACKED_EXAMPLE_MODEL_PACK_PATH + + +class ContractingTestingTests(TestCase): + + @classmethod + def setUpClass(cls) -> None: + cls._model = CAT.load_model_pack(UNPACKED_EXAMPLE_MODEL_PACK_PATH) + + @contextmanager + def empty_cdb(self): + with captured_state_cdb(self._model.cdb): + self._model.cdb.name2info.clear() + self._model.cdb.cui2info.clear() + yield + + def test_contracting_normally_passes(self): + contracting_testing.assert_component_contracts(self._model) + + def test_contracting_fails_with_empty_cdb(self): + with self.empty_cdb(): + with self.assertRaises(contracting_testing.ContractViolationError): + contracting_testing.assert_component_contracts(self._model) + + def test_contracting_fails_with_no_entity(self): + with self.assertRaises(contracting_testing.ContractViolationError): + contracting_testing.assert_component_contracts( + self._model, "Text with no entities") + + def test_contracting_fails_with_no_tokens(self): + with self.assertRaises(contracting_testing.ContractViolationError): + contracting_testing.assert_component_contracts( + self._model, "") From 61a05a36b4c0ee17dc929581f497c2e39a596400 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 19:01:35 +0100 Subject: [PATCH 35/38] Add a comment on what collection contract check does --- medcat-v2/medcat/components/contracting.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/medcat-v2/medcat/components/contracting.py b/medcat-v2/medcat/components/contracting.py index a44b5f0fa..8afe55e40 100644 --- a/medcat-v2/medcat/components/contracting.py +++ b/medcat-v2/medcat/components/contracting.py @@ -141,6 +141,13 @@ def verify_collections_contracts( ) -> list[str]: """Verify the collection contracts portion of a contract. + This method only really checks the length of the collection + and that each item in there has a truthy value for each required + field. The expectation is that the write action (for the collection) + is checked by other parts. And because these entities may be created + in order to put them in the lists (i.e for NER) without the data filled + in, it's fair to assume that if the data exists, it was filled in. + Args: text (str): The text to use. doc_getter (Callable[[str], MutableDocument]): The document getter. From 2c685c5df18986b46f6b90c5c023b8cf57d7cf2f Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 19:05:28 +0100 Subject: [PATCH 36/38] Rename method in doc string example --- medcat-v2/medcat/components/contracting_testing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index 614c1b574..36a52e480 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -85,7 +85,7 @@ def assert_component_contracts( Example: - def test_my_ner_contract(self): + def test_my_model_contract(self): cat = create_model_with_my_component() assert_component_contract(cat) From c221dd9ec53375493c892e8bc8c109fb48b56de4 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 16 Jul 2026 19:07:33 +0100 Subject: [PATCH 37/38] Fix typo in doc string --- medcat-v2/medcat/components/contracting_testing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-v2/medcat/components/contracting_testing.py b/medcat-v2/medcat/components/contracting_testing.py index 36a52e480..9cfffd75d 100644 --- a/medcat-v2/medcat/components/contracting_testing.py +++ b/medcat-v2/medcat/components/contracting_testing.py @@ -87,7 +87,7 @@ def assert_component_contracts( def test_my_model_contract(self): cat = create_model_with_my_component() - assert_component_contract(cat) + assert_component_contracts(cat) Args: model (CAT): The model pack to use. This needs to refer to a model that From 83189ceca501dd914f3619b7692e30b62f523202 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Mon, 20 Jul 2026 15:48:14 +0100 Subject: [PATCH 38/38] Add mention to README --- medcat-v2/README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/medcat-v2/README.md b/medcat-v2/README.md index a8248c195..244b1a4c3 100644 --- a/medcat-v2/README.md +++ b/medcat-v2/README.md @@ -151,6 +151,29 @@ See [Architecture Documentation](docs/architecture.md) for detailed information. ## Tutorials A guide on how to use MedCAT v2 is available at on the medcat documentation page on [docs.cogstack.org](https://docs.cogstack.org) +## Contributing + +Please follow the [Contribution Guidelines](../CONTRIBUTING.md). + +When writing your own component (NER or linker), it is recommended making sure they follow the contracts for these components. +
+Example test for custom components in model pack + +```python +from unittest import TestCase +from medcat.components.contracting_testing import assert_component_contracts +# implement create_model_with_my_component +class MyComponentTest(TestCase): + def test_my_model_contract(self): + # create or load a model with your custom component(s) + # NOTE: This would (generally) need to be able to NER / link 1 entity in the example text + # The test time models in medcat would be sufficient + cat = create_model_with_my_component() + assert_component_contracts(cat) +``` +
+ + ## Acknowledgements Entity extraction was trained on [MedMentions](https://github.com/chanzuckerberg/MedMentions) In total it has ~ 35K entites from UMLS