Skip to content

Python API

Not a stable interface

kennis.engine exports nothing from its __init__, and v0.1 has no curated public API. What is shown here is the surface a caller plausibly reaches for. Everything else under kennis.engine is internal and will change without a deprecation path, because this project keeps none.

The command line is the supported interface. See Commands.

The docstrings below are essays about why a mechanism has the shape it does, because that is what they are in the source. Parameter types come from the annotations, which are complete - kennis is checked with mypy in strict mode and has no bare dict and no type: ignore.

Searching

search

Ranked search over a built index.

Two retrievers run over one ordered chunk list and are combined by reciprocal rank fusion: a chunk scores sum 1 / (k + rank) over the lists it appears in, and only its position in each list is used.

Fusing positions rather than scores is the whole point. BM25 returns an unbounded, corpus-relative term score; cosine similarity returns a number in [-1, 1]. Normalising one onto the other means knowing both distributions, which change with every build. Positions need no such knowledge, and with one retriever available the result is simply that retriever's ordering - so a lexical-only index, or a query with no lexical overlap, degrades cleanly rather than as a special case.

DocumentSpan dataclass

A contiguous run of one document's chunks, stitched back into text.

Every coordinate here is one search also surfaces, so a hit's document_id and chunk_index go straight back into a read request.

Source code in src/kennis/engine/rag/search.py
@dataclass(frozen=True, slots=True)
class DocumentSpan:
    """A contiguous run of one document's chunks, stitched back into text.

    Every coordinate here is one search also surfaces, so a hit's
    `document_id` and `chunk_index` go straight back into a read request.
    """

    document_id: str
    source_path: str
    chunk_start: int
    chunk_end: int
    char_start: int
    char_end: int
    sections: list[str]
    text: str
    metadata: Metadata

ChunkRange dataclass

Which chunks of a document to read, as a half-open range.

None at either end means "as far as the document goes", and a negative index counts from the end, both exactly as a python slice does - because resolving one is a python slice, so the semantics are borrowed rather than reimplemented.

There is no step. A stepped selection would stitch non-adjacent chunks into one passage: holes presented as continuous prose, and char_start and char_end describing a range the text does not fill. A passage that is not contiguous is not a passage. Concern #205.

Source code in src/kennis/engine/rag/search.py
@dataclass(frozen=True, slots=True)
class ChunkRange:
    """Which chunks of a document to read, as a half-open range.

    `None` at either end means "as far as the document goes", and a negative
    index counts from the end, both exactly as a python slice does - because
    resolving one *is* a python slice, so the semantics are borrowed rather
    than reimplemented.

    There is no step. A stepped selection would stitch non-adjacent chunks
    into one passage: holes presented as continuous prose, and `char_start`
    and `char_end` describing a range the text does not fill. A passage that
    is not contiguous is not a passage. Concern #205.
    """

    start: int | None = None
    stop: int | None = None

    def resolve(self, count: int) -> tuple[int, int]:
        """The concrete `[start, stop)` over a document of `count` chunks."""
        start, stop, _ = slice(self.start, self.stop).indices(count)
        return start, stop
resolve
resolve(count: int) -> tuple[int, int]

The concrete [start, stop) over a document of count chunks.

Source code in src/kennis/engine/rag/search.py
def resolve(self, count: int) -> tuple[int, int]:
    """The concrete `[start, stop)` over a document of `count` chunks."""
    start, stop, _ = slice(self.start, self.stop).indices(count)
    return start, stop

search

search(index: LoadedIndex, question: str, *, top_k: int = _DEFAULT_TOP_K, filters: list[Filter] | None = None, mode: Mode = 'hybrid', embedder: Embedder | None = None, host: str | None = None, events: EventSink | None = None) -> list[SearchResult]

The best top_k chunks for question, best first.

The query is embedded with the index's own binding, read back from the index rather than taken from the caller's current configuration. A query vector computed with a different model is meaningless against these rows, and would not error - it would return confident nonsense.

host is the exception, and it is not one: an address is not part of what the vectors are, so it is not in the stored binding and has to come from the configuration in force now. Without it an ollama index cannot be searched at all, because validate_binding refuses a daemon backend with nowhere to reach. Concern #210.

filters are applied to each leg's candidate list before fusion. That is the cheap choice, and it has a consequence worth knowing: a filter selective enough that its matches rank below the candidate window returns fewer results than exist, or none. Nothing false is returned; the answer is incomplete rather than wrong.

Source code in src/kennis/engine/rag/search.py
def search(
    index: LoadedIndex,
    question: str,
    *,
    top_k: int = _DEFAULT_TOP_K,
    filters: list[Filter] | None = None,
    mode: Mode = "hybrid",
    embedder: Embedder | None = None,
    host: str | None = None,
    events: EventSink | None = None,
) -> list[SearchResult]:
    """The best `top_k` chunks for `question`, best first.

    **The query is embedded with the index's own binding**, read back from
    the index rather than taken from the caller's current configuration. A
    query vector computed with a different model is meaningless against these
    rows, and would not error - it would return confident nonsense.

    `host` is the exception, and it is not one: an address is not part of
    what the vectors are, so it is not in the stored binding and has to come
    from the configuration in force now. Without it an ollama index cannot be
    searched at all, because `validate_binding` refuses a daemon backend with
    nowhere to reach. Concern #210.

    `filters` are applied to each leg's candidate list before fusion. That is
    the cheap choice, and it has a consequence worth knowing: a filter
    selective enough that its matches rank below the candidate window returns
    fewer results than exist, or none. Nothing false is returned; the answer
    is incomplete rather than wrong.
    """
    if mode in ("hybrid", "dense") and index.matrix is None:
        raise SearchUnavailable(
            f"the '{index.collection}' index was built without an embedding "
            f"backend, so it has no dense leg to run a '{mode}' search against; "
            "search it with mode 'bm25' instead"
        )

    predicate = combine_filters(filters)
    candidates = max(top_k, _CANDIDATES)

    dense_ranks, cosines = (
        _dense_leg(index, question, candidates, predicate, embedder, host, events)
        if mode in ("hybrid", "dense")
        else ([], None)
    )
    bm25_ranks, bm25_scores = (
        _bm25_leg(index, question, candidates, predicate)
        if mode in ("hybrid", "bm25")
        else ([], {})
    )

    return [
        SearchResult(
            chunk=index.chunks[position],
            score=score,
            dense_rank=_rank_of(position, dense_ranks),
            bm25_rank=_rank_of(position, bm25_ranks),
            bm25_score=bm25_scores.get(position),
            # Every position, not only the ones inside the dense window.
            # Cosine is defined for every chunk, and the window is a
            # retrieval budget rather than a limit on what is known - a hit
            # fused in by the lexical leg alone is still a hit whose
            # similarity can be reported.
            dense_score=None if cosines is None else float(cosines[position]),
        )
        for position, score in _fuse(dense_ranks, bm25_ranks)[:top_k]
    ]

parse_chunk_range

parse_chunk_range(text: str) -> ChunkRange

--chunks as a range, or InputError naming the forms that work.

Engine-side rather than in the command line, because the syntax is kennis's own: an MCP tool taking chunks="0:3" has to get the same answer, and a second parser is how two front ends come to disagree about what -1 means.

Source code in src/kennis/engine/rag/search.py
def parse_chunk_range(text: str) -> ChunkRange:
    """`--chunks` as a range, or `InputError` naming the forms that work.

    Engine-side rather than in the command line, because the syntax is
    kennis's own: an MCP tool taking `chunks="0:3"` has to get the same
    answer, and a second parser is how two front ends come to disagree about
    what `-1` means.
    """
    index = _INDEX.match(text)
    if index is not None:
        position = int(index.group(1))
        # A bare index is that one chunk. Its half-open stop is the next
        # position - except from the end, where the next position after -1 is
        # 0, which python reads as "before the beginning" and which selects
        # nothing. None is the only spelling of "to the end".
        return ChunkRange(start=position, stop=position + 1 if position >= 0 else None)

    found = _RANGE.match(text)
    if found is not None:
        start, stop = found.groups()
        return ChunkRange(
            start=int(start) if start is not None else None,
            stop=int(stop) if stop is not None else None,
        )

    if text.count(":") > 1:
        raise InputError(
            f"'{text}' has a step, and a passage is contiguous; read a range instead",
            resolution="kennis read --help",
        )
    raise InputError(
        f"'{text}' is not a chunk range; write one of {_ACCEPTED}",
        resolution="kennis read --help",
    )

read_span

read_span(index: LoadedIndex, document_id: str, *, chunks: ChunkRange) -> DocumentSpan

One contiguous run of a document's chunks, stitched back into text.

The range is resolved against the chunks this document actually has, in the order it has them, so -1 is its last chunk rather than the last row of the index.

Source code in src/kennis/engine/rag/search.py
def read_span(
    index: LoadedIndex,
    document_id: str,
    *,
    chunks: ChunkRange,
) -> DocumentSpan:
    """One contiguous run of a document's chunks, stitched back into text.

    The range is resolved against the chunks this document actually has, in
    the order it has them, so `-1` is its last chunk rather than the last row
    of the index.
    """
    held = sorted(
        (chunk for chunk in index.chunks if chunk.document_id == document_id),
        key=lambda chunk: chunk.chunk_index,
    )
    if not held:
        raise SearchUnavailable(
            f"'{document_id}' is not in the '{index.collection}' index",
            resolution=f"kennis corpus index --collection {index.collection}",
        )

    start, stop = chunks.resolve(len(held))
    run = held[start:stop]
    if not run:
        # A legal slice that selects nothing - `5:5`, or a range past the
        # end. Returning empty text would show a reader a blank screen and
        # let them conclude the document was empty.
        raise SearchUnavailable(
            f"chunks {start}:{stop} of '{document_id}' select nothing; "
            f"it has {len(held)}",
            resolution=f"kennis read {document_id}",
        )

    return DocumentSpan(
        document_id=document_id,
        source_path=run[0].source_path,
        chunk_start=run[0].chunk_index,
        chunk_end=run[-1].chunk_index,
        char_start=run[0].char_start,
        char_end=run[-1].char_end,
        sections=list(
            dict.fromkeys(chunk.section for chunk in run if chunk.section is not None)
        ),
        text=_stitch(run),
        metadata=dict(run[0].metadata),
    )

Settings

The Settings model itself is on the Configuration page.

The model itself is on the Configuration page.

settings

What can be tuned, and where an answer comes from.

Precedence is the environment, then config.toml, then the field's default.

The environment is read by exact name, not by splitting on a delimiter. pydantic-settings offers env_nested_delimiter="_", and design section 13 specifies against it for a reason that is easy to verify and easy to miss:

KENNIS_EMBEDDING_BACKEND     -> embedding.backend      works
KENNIS_EMBEDDING_API_KEY_ENV -> embedding.api.key.env  matches nothing

The second is dropped in silence. No error, no warning; the field keeps its default, and a user who set the variable and watched it do nothing has no way to find out why. So the source below walks the model's own sections and fields and looks up KENNIS_<SECTION>_<FIELD> by exact name.

Only settings something reads are offered. A setting nothing honours is worse than a missing one: it is a promise. ingestion.use_mcp_sampling, which the design marks deferred, is deliberately absent.

Credentials are not settings. config show writes this model to standard output by design, so a user debugging will paste it into an issue. A key here would make every printing path need redaction, and redaction is the step that gets forgotten when a new printing path is added. Keys live outside this model, and credential below is the one function that reads them.

config_path

config_path() -> Path
Source code in src/kennis/engine/settings.py
def config_path() -> Path:
    return config_dir() / _CONFIG_FILE

credentials_path

credentials_path() -> Path

Where config init writes API keys. credential reads it last.

Source code in src/kennis/engine/settings.py
def credentials_path() -> Path:
    """Where `config init` writes API keys. `credential` reads it last."""
    return config_dir() / _CREDENTIALS_FILE

load_settings

load_settings() -> Settings

Every setting, resolved.

A value that is refused is named with where it came from, and the remedy is the one that removes it: pointing at config show for an exported variable sent the reader to a file with nothing wrong in it.

Source code in src/kennis/engine/settings.py
def load_settings() -> Settings:
    """Every setting, resolved.

    A value that is refused is named with where it came from, and the
    remedy is the one that removes it: pointing at `config show` for an
    exported variable sent the reader to a file with nothing wrong in it.
    """
    try:
        return Settings()
    except ValidationError as error:
        raise refused_setting(error, config_path()) from error

Events

An operation reports progress by emitting events. A front end subscribes; nothing in the engine knows what a progress bar is.

events

What a long-running engine operation reports while it runs.

Rather than returning only a final result, an operation emits a sequence of typed events: an item was started, an item finished with a particular outcome and reason, progress advanced, a diagnostic was produced, the operation finished with a summary. A front end subscribes and decides what to do with them.

One mechanism, four consumers. The command line prints operation lines and detail markers and drives the progress bar; the MCP server accumulates events and returns a summary; a graphical interface updates widgets; and logging is a subscriber that writes every event to the log file. That last one is the reason to prefer a stream over a progress callback: the log has to record what the report omits - which fifteen documents were skipped and why each one - and with a stream the logging subscriber receives every event by construction, so the asymmetry between the log and the report is a difference in filtering rather than a difference in instrumentation.

Events are frozen: an event is a record of something that already happened, so no subscriber may edit one before the next sees it.

Outcome

Bases: Enum

What became of one item.

The same vocabulary the report uses: ADDED, REMOVED and CHANGED are what the detail markers +, - and ~ render, UNCHANGED is the = that carries no colour because it is the absence of news, and SKIPPED and FAILED are the two ways an item can be passed over - one deliberate, one not.

Source code in src/kennis/engine/events.py
class Outcome(Enum):
    """What became of one item.

    The same vocabulary the report uses: `ADDED`, `REMOVED` and `CHANGED` are
    what the detail markers `+`, `-` and `~` render, `UNCHANGED` is the `=`
    that carries no colour because it is the absence of news, and `SKIPPED`
    and `FAILED` are the two ways an item can be passed over - one deliberate,
    one not.
    """

    ADDED = "added"
    REMOVED = "removed"
    CHANGED = "changed"
    UNCHANGED = "unchanged"
    SKIPPED = "skipped"
    FAILED = "failed"

Severity

Bases: Enum

How much attention a diagnostic is asking for.

Three, matching the three shapes the terminal has for one: warning: and error: at the margin, and hint: indented under the line it follows.

Source code in src/kennis/engine/events.py
class Severity(Enum):
    """How much attention a diagnostic is asking for.

    Three, matching the three shapes the terminal has for one: `warning:` and
    `error:` at the margin, and `hint:` indented under the line it follows.
    """

    WARNING = "warning"
    ERROR = "error"
    HINT = "hint"

ItemStarted dataclass

Work began on one item. Named so a front end can show what is in flight before there is an outcome to report.

Source code in src/kennis/engine/events.py
@dataclass(frozen=True, slots=True)
class ItemStarted:
    """Work began on one item. Named so a front end can show what is in flight
    before there is an outcome to report."""

    operation: str
    item: str

ItemFinished dataclass

Work on one item ended, with an outcome and optionally why.

The refusal is what the log keeps and the report usually drops: the report says a fetch skipped fifteen documents, the log says which fifteen and why each one. kennis corpus add is the exception and prints its failures, because one refused paper is not fifteen.

refusal is typed rather than a sentence for the reason Diagnostic.detail is - the engine names the condition and carries its facts, and render/refusals.py chooses the words. A caller whose reason is genuinely a name produced elsewhere wraps it in Quoted.

Source code in src/kennis/engine/events.py
@dataclass(frozen=True, slots=True)
class ItemFinished:
    """Work on one item ended, with an outcome and optionally why.

    The refusal is what the log keeps and the report usually drops: the
    report says a fetch skipped fifteen documents, the log says which
    fifteen and why each one. `kennis corpus add` is the exception and
    prints its failures, because one refused paper is not fifteen.

    `refusal` is typed rather than a sentence for the reason
    `Diagnostic.detail` is - the engine names the condition and carries
    its facts, and `render/refusals.py` chooses the words. A caller whose
    reason is genuinely a name produced elsewhere wraps it in `Quoted`.
    """

    operation: str
    item: str
    outcome: Outcome
    refusal: Refusal | None = None

Progress dataclass

How far through the operation is.

total is absent where it is not yet known - chunking cannot say how many chunks there are until it has finished - and a front end drawing a bar renders that as an indeterminate spinner until a later event supplies one.

Source code in src/kennis/engine/events.py
@dataclass(frozen=True, slots=True)
class Progress:
    """How far through the operation is.

    `total` is absent where it is not yet known - chunking cannot say how many
    chunks there are until it has finished - and a front end drawing a bar
    renders that as an indeterminate spinner until a later event supplies one.
    """

    operation: str
    completed: int
    total: int | None = None

DocumentUnsearchable dataclass

A corpus document an indexer could not read, so it is left out.

problem is what the reader could say about it and is None when it could say nothing, which is a different sentence rather than an empty one.

Source code in src/kennis/engine/events.py
@dataclass(frozen=True, slots=True)
class DocumentUnsearchable:
    """A corpus document an indexer could not read, so it is left out.

    `problem` is what the reader could say about it and is None when it
    could say nothing, which is a different sentence rather than an
    empty one.
    """

    code: ClassVar[str] = "document-unsearchable"
    problem: str | None
    path: str

FrontmatterUnreadable dataclass

A bundle file whose header would not parse. Indexed without it.

Source code in src/kennis/engine/events.py
@dataclass(frozen=True, slots=True)
class FrontmatterUnreadable:
    """A bundle file whose header would not parse. Indexed without it."""

    code: ClassVar[str] = "frontmatter-unreadable"
    path: str

TitleDotStripped dataclass

A title that looked like a dotfile name, so the dot was dropped.

Source code in src/kennis/engine/events.py
@dataclass(frozen=True, slots=True)
class TitleDotStripped:
    """A title that looked like a dotfile name, so the dot was dropped."""

    code: ClassVar[str] = "title-dot-stripped"
    title: str

DocumentSkipped dataclass

A document that could not be read while adding, and was not touched.

Distinct from DocumentUnsearchable although both are an unreadable document: the consequence differs, and the consequence is what a reader needs. One will not be found by a search; this one was left exactly as it was.

Source code in src/kennis/engine/events.py
@dataclass(frozen=True, slots=True)
class DocumentSkipped:
    """A document that could not be read while adding, and was not touched.

    Distinct from `DocumentUnsearchable` although both are an unreadable
    document: the consequence differs, and the consequence is what a
    reader needs. One will not be found by a search; this one was left
    exactly as it was.
    """

    code: ClassVar[str] = "document-skipped"
    problem: str

MetadataUnavailable dataclass

A paper whose metadata lookup returned nothing.

The document is still written - refusing to store a paper because a service was unreachable trades a small loss for a total one - so this says what was lost rather than that something failed.

Source code in src/kennis/engine/events.py
@dataclass(frozen=True, slots=True)
class MetadataUnavailable:
    """A paper whose metadata lookup returned nothing.

    The document is still written - refusing to store a paper because a
    service was unreachable trades a small loss for a total one - so
    this says what was lost rather than that something failed.
    """

    code: ClassVar[str] = "metadata-unavailable"
    identifier: str

NoPagesDiscovered dataclass

A documentation site that yielded nothing.

path_prefix is the surprising half and is None when there was none: it is derived from the URL, so a link deeper than the documentation root narrows the crawl without anyone asking.

Source code in src/kennis/engine/events.py
@dataclass(frozen=True, slots=True)
class NoPagesDiscovered:
    """A documentation site that yielded nothing.

    `path_prefix` is the surprising half and is None when there was
    none: it is derived from the URL, so a link deeper than the
    documentation root narrows the crawl without anyone asking.
    """

    code: ClassVar[str] = "no-pages-discovered"
    base_url: str
    mode: str
    path_prefix: str | None

PassagesCutShort dataclass

Passages an index build embedded that model does not read whole (#577).

count of the total embedded in this build were longer than limit tokens. shortened says who cut them: True when kennis shortened what it sent, so a request could not be refused for length (OpenAI); False when the model truncates by itself and only the count is kennis's (fastembed). Either way the keyword index holds the whole passage.

Source code in src/kennis/engine/events.py
@dataclass(frozen=True, slots=True)
class PassagesCutShort:
    """Passages an index build embedded that `model` does not read whole
    (#577).

    `count` of the `total` embedded in this build were longer than `limit`
    tokens. `shortened` says who cut them: True when kennis shortened what
    it sent, so a request could not be refused for length (OpenAI); False
    when the model truncates by itself and only the count is kennis's
    (fastembed). Either way the keyword index holds the whole passage.
    """

    code: ClassVar[str] = "passages-cut-short"
    model: str
    limit: int
    count: int
    total: int
    shortened: bool

Diagnostic dataclass

Something worth saying that is not an item's outcome.

The engine names the condition; it does not phrase it. detail says what happened and carries the facts as fields, and render/ turns one into a sentence. This used to be a message: str the engine composed and the command line printed unchanged, which was the one event type that bypassed render/ entirely - harmless with a single front end and wrong the moment there are two. Concern #285.

severity stays here rather than on each condition because it is orthogonal: it says how loudly to say a thing, not what the thing is, and it is what a front end switches on to choose its prefix.

resolution is the command that would fix it, carried for the same reason KennisError carries one: the advice is chosen where the problem is found, not reconstructed afterwards from state.

Source code in src/kennis/engine/events.py
@dataclass(frozen=True, slots=True)
class Diagnostic:
    """Something worth saying that is not an item's outcome.

    **The engine names the condition; it does not phrase it.** `detail`
    says what happened and carries the facts as fields, and `render/`
    turns one into a sentence. This used to be a `message: str` the
    engine composed and the command line printed unchanged, which was
    the one event type that bypassed `render/` entirely - harmless with
    a single front end and wrong the moment there are two. Concern #285.

    `severity` stays here rather than on each condition because it is
    orthogonal: it says how loudly to say a thing, not what the thing
    is, and it is what a front end switches on to choose its prefix.

    `resolution` is the command that would fix it, carried for the same
    reason `KennisError` carries one: the advice is chosen where the
    problem is found, not reconstructed afterwards from state.
    """

    severity: Severity
    detail: DiagnosticDetail
    resolution: str | None = None

OperationFinished dataclass

The operation ended. counts is the per-outcome tally the report summarises, and elapsed_seconds is what the operation line prints as in 1.4s.

Source code in src/kennis/engine/events.py
@dataclass(frozen=True, slots=True)
class OperationFinished:
    """The operation ended. `counts` is the per-outcome tally the report
    summarises, and `elapsed_seconds` is what the operation line prints as
    `in 1.4s`."""

    operation: str
    elapsed_seconds: float
    counts: Mapping[Outcome, int] = field(default_factory=dict)

EventSink

Bases: Protocol

What an operation is handed to report through.

One method, because the engine's side of this is deliberately trivial: a front end that wants to batch, filter or forward does so in its own implementation rather than by being asked for a richer interface.

Source code in src/kennis/engine/events.py
class EventSink(Protocol):
    """What an operation is handed to report through.

    One method, because the engine's side of this is deliberately trivial: a
    front end that wants to batch, filter or forward does so in its own
    implementation rather than by being asked for a richer interface.
    """

    def emit(self, event: Event) -> None: ...

DiscardingSink

The sink a caller gets when it wants only the report.

Source code in src/kennis/engine/events.py
class DiscardingSink:
    """The sink a caller gets when it wants only the report."""

    def emit(self, event: Event) -> None:
        return None

Recorder

An EventSink that keeps everything, so a test can assert what an operation reported without driving a front end.

Source code in src/kennis/engine/events.py
class Recorder:
    """An `EventSink` that keeps everything, so a test can assert what an
    operation reported without driving a front end."""

    def __init__(self) -> None:
        self.events: list[Event] = []

    def emit(self, event: Event) -> None:
        self.events.append(event)

    def events_of_type[EventType: Event](
        self, event_type: type[EventType]
    ) -> Sequence[EventType]:
        """Every recorded event of one type, in the order they arrived."""
        return [event for event in self.events if isinstance(event, event_type)]

    def outcomes(self) -> dict[str, Outcome]:
        """What each item ended as. A later outcome for the same item wins,
        which is what a retry means."""
        return {
            event.item: event.outcome
            for event in self.events
            if isinstance(event, ItemFinished)
        }
events_of_type
events_of_type(event_type: type[EventType]) -> Sequence[EventType]

Every recorded event of one type, in the order they arrived.

Source code in src/kennis/engine/events.py
def events_of_type[EventType: Event](
    self, event_type: type[EventType]
) -> Sequence[EventType]:
    """Every recorded event of one type, in the order they arrived."""
    return [event for event in self.events if isinstance(event, event_type)]
outcomes
outcomes() -> dict[str, Outcome]

What each item ended as. A later outcome for the same item wins, which is what a retry means.

Source code in src/kennis/engine/events.py
def outcomes(self) -> dict[str, Outcome]:
    """What each item ended as. A later outcome for the same item wins,
    which is what a retry means."""
    return {
        event.item: event.outcome
        for event in self.events
        if isinstance(event, ItemFinished)
    }