Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
5f0437d
Move some basic typing things to its own module
github-actions[bot] Jun 8, 2026
faa9fb0
Add initial contracting bits
github-actions[bot] Jun 9, 2026
8eb1944
Remove unused import
github-actions[bot] Jun 9, 2026
3072f9e
Finalise contracting module
github-actions[bot] Jun 9, 2026
7226bf4
Add debug logging regarding violations as they come
github-actions[bot] Jun 9, 2026
d2d7258
Shuffle classes around a little to avoid circular imports
github-actions[bot] Jun 9, 2026
99b8013
Fix minor issue in contracting module
github-actions[bot] Jun 9, 2026
69e52a9
Fix minor issue in violation formatting
github-actions[bot] Jun 9, 2026
7a2f2a8
Allow running pipe until specific component
github-actions[bot] Jun 9, 2026
9f285f6
Update copies of state in contracting
github-actions[bot] Jun 9, 2026
1458f95
Add minimum feedback numbers
github-actions[bot] Jun 18, 2026
3925020
Add initial contracting tests (WIP)
mart-r Jun 24, 2026
f400330
Merge branch 'main' into feat/medcat/add-component-contracting
mart-r Jun 29, 2026
d1c0c3b
Merge branch 'main' into feat/medcat/add-component-contracting
mart-r Jul 16, 2026
f2ac530
Update contracting to fix token spying
mart-r Jul 16, 2026
251e5f6
Move some contracting stuff to the a separate utility module
mart-r Jul 16, 2026
5aee12d
Add doc strings to various methods
mart-r Jul 16, 2026
639d998
Add type ignoring comments where needed
mart-r Jul 16, 2026
da62a7d
Add initial contracting tests
mart-r Jul 16, 2026
fff249a
Fix typo
mart-r Jul 16, 2026
b58e52b
Yield a live container rather than converting where appropriate
mart-r Jul 16, 2026
1310812
Amend tests to assert non-trivial number of needs
mart-r Jul 16, 2026
8ee0176
Add comment to tests
mart-r Jul 16, 2026
0ed1490
Infer number of min needed feedback automatically
mart-r Jul 16, 2026
990c0b8
Add a contracting testing module
mart-r Jul 16, 2026
727406a
Specify default value in module rather than within method
mart-r Jul 16, 2026
f60ff01
Avoid testing min feedbacks 0 (i.e if nothing to tokenize)
mart-r Jul 16, 2026
6d8507b
Simplify contracting tests
mart-r Jul 16, 2026
f6566d3
Add extra test for individual components
mart-r Jul 16, 2026
6f15e24
Add example to single-component check
mart-r Jul 16, 2026
70ee5d4
Make a better exception be raised if/when contracts do not hold
mart-r Jul 16, 2026
0523f0b
Fix exception creation
mart-r Jul 16, 2026
23ea94e
Add collection contracting tp contract checks
mart-r Jul 16, 2026
334ec1f
Expect at least one entity in output
mart-r Jul 16, 2026
47aab1b
Add a few comments to testing utils
mart-r Jul 16, 2026
8671d24
Add a few tests for the contracting testing module
mart-r Jul 16, 2026
61a05a3
Add a comment on what collection contract check does
mart-r Jul 16, 2026
2c685c5
Rename method in doc string example
mart-r Jul 16, 2026
c221dd9
Fix typo in doc string
mart-r Jul 16, 2026
83189ce
Add mention to README
mart-r Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions medcat-v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<details>
<summary>Example test for custom components in model pack</summary>

```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)
```
</details>


## Acknowledgements
Entity extraction was trained on [MedMentions](https://github.com/chanzuckerberg/MedMentions) In total it has ~ 35K entites from UMLS

Expand Down
109 changes: 109 additions & 0 deletions medcat-v2/medcat/components/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
from typing import Protocol, runtime_checkable, Optional
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


@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 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(
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'}),
),
}),
)
241 changes: 241 additions & 0 deletions medcat-v2/medcat/components/contracting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
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__)


def verify_part(
text: str,
doc_getter: Callable[[str], MutableDocument],
component: BaseComponent,
paths: list[str],
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)
with wrap_relevant_parts(doc, path) as feedback:
doc = component(doc)
# verify each one access
accessed = sum(bool(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)")
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,
)
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


def verify_needs(
text: str,
doc_getter: Callable[[str], MutableDocument],
component: BaseComponent,
contract: ComponentContract,
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,
min_feedbacks=min_feedbacks,
)


def verify_must_provide(
text: str,
doc_getter: Callable[[str], MutableDocument],
component: BaseComponent,
contract: ComponentContract,
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,
min_feedbacks=min_feedbacks,
)


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.

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.
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],
component: BaseComponent,
contract: ComponentContract,
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.
Returns a list of violation messages.

Raises ContractViolation if violations found and raise_on_violation.
"""
# verify needs are met
violations = verify_needs(
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,
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))

return violations
Loading