Source code for knowledgespaces.query.expert
"""
Expert protocols for the QUERY algorithm.
An expert is any callable that answers prerequisite queries.
This module defines the protocol and provides built-in implementations
for testing and interactive use.
References:
Koppen, M., & Doignon, J.-P. (1990).
How to build a knowledge space by querying an expert.
Journal of Mathematical Psychology, 34, 311-331.
"""
from __future__ import annotations
from collections.abc import Callable, Collection
from typing import Protocol, runtime_checkable
from knowledgespaces.query.types import Query
[docs]
@runtime_checkable
class Expert(Protocol):
"""Protocol for expert query functions.
An expert receives a Query and returns True (positive) or False (negative).
The semantic of a positive answer to Query(A, q) is:
'If a student fails all items in A, they will also fail q.'
Equivalently: mastering q requires mastering at least one item in A.
"""
def __call__(self, query: Query) -> bool: ...
[docs]
class PresetExpert:
"""Expert with predetermined answers, for testing and replay.
Parameters
----------
answers : dict[tuple[frozenset[str], str], bool]
Mapping from (antecedent, consequent) to answer.
default : bool
Answer for queries not in the mapping.
"""
def __init__(
self,
answers: dict[tuple[frozenset[str], str], bool] | None = None,
default: bool = False,
) -> None:
self._answers: dict[tuple[frozenset[str], str], bool] = dict(answers or {})
self._default = default
def __call__(self, query: Query) -> bool:
key = (query.antecedent, query.consequent)
return self._answers.get(key, self._default)
[docs]
@classmethod
def from_relation(cls, relations: Collection[tuple[str, str]]) -> PresetExpert:
"""Create an expert that answers based on a known surmise relation.
A pair query (a -> q) is positive iff (a, q) is in the relation.
A group query (A -> q) is positive iff some a in A has (a, q).
This simulates an expert who knows the true prerequisite structure.
Suitable for testing the query algorithm against a known ground truth.
"""
rel_set = set(relations)
# We don't pre-compute all queries; instead override __call__
expert = cls.__new__(cls)
expert._answers = {}
expert._default = False
expert._relation = rel_set
expert.__class__ = _RelationExpert
return expert # type: ignore[return-value]
class _RelationExpert:
"""Expert backed by a known surmise relation (transitive closure)."""
_relation: set[tuple[str, str]]
def __call__(self, query: Query) -> bool:
# Positive iff there exists a in A such that (a, q) in relation
# This implements monotonicity: if {a} -> q, then any A containing a -> q
return any((a, query.consequent) in self._relation for a in query.antecedent)
[docs]
class CallbackExpert:
"""Expert that delegates to a user-supplied function.
Parameters
----------
fn : Callable[[frozenset[str], str], bool]
A function that takes (antecedent_set, consequent) and returns bool.
"""
def __init__(self, fn: Callable[[frozenset[str], str], bool]) -> None:
self._fn = fn
def __call__(self, query: Query) -> bool:
return self._fn(query.antecedent, query.consequent)