"""
Block 1 of the QUERY algorithm: surmise relation discovery.
Extracts prerequisite relations between items by querying an expert
with pair queries (a -> q) and optional Qmax minimality tests.
**Implementation note — multi-phase optimization.**
The standard QUERY Block 1 (Koppen & Doignon, 1990; Learning Spaces,
Falmagne & Doignon, 2011, Ch. 4) iterates over all item pairs and
uses transitivity + antisymmetry to prune redundant queries.
This implementation splits Block 1 into four phases as an
*optimization* that maximises early inference opportunities:
- Phase 0 (optional): Qmax test — group query Q\\{q} → q for each item,
identifies globally minimal items that need no pair queries.
- Phase 1: Forward pass — pair queries with transitivity/antisymmetry
pruning, processing items in input order.
- Phase 2: Bottom-up pass — re-explores from minimal items outward,
exploiting newly discovered transitivity.
- Phase 3: Final verification sweep — covers any pair not yet resolved
by Phases 1–2, guaranteeing completeness.
The result is identical to the standard single-pass algorithm: the same
surmise relation is discovered, but typically with fewer expert queries
thanks to the ordering strategy. Completeness is guaranteed by Phase 3.
References:
Koppen, M., & Doignon, J.-P. (1990).
How to build a knowledge space by querying an expert.
Journal of Mathematical Psychology, 34, 311-331.
Falmagne, J.-C., & Doignon, J.-P. (2011).
Learning Spaces, Chapter 4. Springer-Verlag.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from knowledgespaces.query.expert import Expert
from knowledgespaces.query.types import InferenceSource, Query, QueryAnswer
from knowledgespaces.structures.relations import SurmiseRelation
[docs]
@dataclass
class Block1Stats:
"""Statistics from Block 1 execution."""
pair_queries: int = 0
group_queries: int = 0
deduced_by_transitivity: int = 0
skipped_by_antisymmetry: int = 0
@property
def total_queries(self) -> int:
return self.pair_queries + self.group_queries
@property
def total_inferences(self) -> int:
return self.deduced_by_transitivity + self.skipped_by_antisymmetry
[docs]
@dataclass
class Block1Result:
"""Result of Block 1 query phase."""
relation: SurmiseRelation
closure: SurmiseRelation
minimal_global: frozenset[str]
minimal_local: frozenset[str]
stats: Block1Stats
log: list[QueryAnswer] = field(default_factory=list)
@property
def minimal_all(self) -> frozenset[str]:
return self.minimal_global | self.minimal_local
[docs]
def run_block1(
items: list[str],
expert: Expert,
*,
use_qmax: bool = True,
) -> Block1Result:
"""Execute Block 1 of the QUERY algorithm.
Parameters
----------
items : list[str]
The domain of items.
expert : Expert
The expert to query.
use_qmax : bool
If True, run Phase 0 (Qmax minimality test). Default True.
Returns
-------
Block1Result
Discovered surmise relation, closure, minimal items, and stats.
"""
relations: set[tuple[str, str]] = set()
asked_pairs: set[tuple[str, str]] = set()
resolved_pairs: set[tuple[str, str]] = set() # all logged pairs (no duplicates)
stats = Block1Stats()
log: list[QueryAnswer] = []
def _closure() -> set[tuple[str, str]]:
return SurmiseRelation(items, relations).transitive_closure().relations
def _record(query: Query, answer: bool, source: InferenceSource) -> None:
log.append(QueryAnswer(query, answer, source))
def _record_pair(r: str, q: str, answer: bool, source: InferenceSource) -> None:
if (r, q) not in resolved_pairs:
resolved_pairs.add((r, q))
log.append(QueryAnswer(Query.pair(r, q), answer, source))
# ------------------------------------------------------------------
# Phase 0: Qmax minimality test
# ------------------------------------------------------------------
minimal_global: set[str] = set()
if use_qmax:
for q in items:
A = frozenset(x for x in items if x != q)
query = Query.group(A, q)
answer = expert(query)
stats.group_queries += 1
_record(query, answer, InferenceSource.EXPERT)
if not answer:
minimal_global.add(q)
# ------------------------------------------------------------------
# Phase 1: Pair queries with pruning
# ------------------------------------------------------------------
minimal_local: set[str] = set()
for q in items:
if q in minimal_global:
continue
has_prereq = False
closure = _closure()
for r in items:
if r == q:
continue
# Transitivity: already deducible
if (r, q) in closure:
stats.deduced_by_transitivity += 1
_record_pair(r, q, True, InferenceSource.TRANSITIVITY)
has_prereq = True
continue
# Antisymmetry: (q, r) in closure forbids (r, q)
if (q, r) in closure:
stats.skipped_by_antisymmetry += 1
_record_pair(r, q, False, InferenceSource.ANTISYMMETRY)
continue
if (r, q) in asked_pairs:
continue
query = Query.pair(r, q)
answer = expert(query)
stats.pair_queries += 1
resolved_pairs.add((r, q))
_record(query, answer, InferenceSource.EXPERT)
if answer:
relations.add((r, q))
has_prereq = True
closure = _closure()
asked_pairs.add((r, q))
if not has_prereq:
minimal_local.add(q)
# ------------------------------------------------------------------
# Phase 2: Bottom-up from minimals
# ------------------------------------------------------------------
all_minimals = minimal_global | minimal_local
processed: set[str] = set()
queue: list[str] = list(all_minimals)
while queue:
current = queue.pop(0)
if current in processed:
continue
closure = _closure()
for q in items:
if q == current or q in all_minimals or q in processed:
continue
if (current, q) in closure:
_record_pair(current, q, True, InferenceSource.TRANSITIVITY)
continue
if (q, current) in closure:
_record_pair(current, q, False, InferenceSource.ANTISYMMETRY)
continue
if (current, q) in asked_pairs:
continue
query = Query.pair(current, q)
answer = expert(query)
stats.pair_queries += 1
resolved_pairs.add((current, q))
_record(query, answer, InferenceSource.EXPERT)
if answer:
relations.add((current, q))
if q not in queue and q not in processed:
queue.append(q)
asked_pairs.add((current, q))
processed.add(current)
# ------------------------------------------------------------------
# Phase 3: Final verification
# ------------------------------------------------------------------
closure = _closure()
for r in items:
for q in items:
if r == q or q in all_minimals:
continue
if (r, q) in asked_pairs:
continue
if (r, q) in closure:
_record_pair(r, q, True, InferenceSource.TRANSITIVITY)
continue
if (q, r) in closure:
_record_pair(r, q, False, InferenceSource.ANTISYMMETRY)
continue
query = Query.pair(r, q)
answer = expert(query)
stats.pair_queries += 1
resolved_pairs.add((r, q))
_record(query, answer, InferenceSource.EXPERT)
if answer:
relations.add((r, q))
closure = _closure()
asked_pairs.add((r, q))
# ------------------------------------------------------------------
# Build result
# ------------------------------------------------------------------
direct = SurmiseRelation(items, relations)
final_closure = direct.transitive_closure()
return Block1Result(
relation=direct,
closure=final_closure,
minimal_global=frozenset(minimal_global),
minimal_local=frozenset(minimal_local),
stats=stats,
log=log,
)