From 7f9846870bd8e543c3783e51b26df0cba5d0550c Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 2 Apr 2026 18:48:03 +0200 Subject: [PATCH 01/42] chore: gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 9804c77..c985141 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ poetry.lock .idea/ target/ +# agents +.cursor/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] From 58899aba27ca1105b92373f507d602b9178c1a6d Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 19 Mar 2026 18:10:19 +0100 Subject: [PATCH 02/42] exp(ontologies): experiments on infering or building target ontologies --- experiments/ontologies/scads-papers.owl.ttl | 221 +++++++++++ experiments/ontologies/scads-papers.ttl | 38 ++ experiments/ontologies/src/onto_chat.py | 404 ++++++++++++++++++++ experiments/ontologies/src/onto_diff.py | 0 4 files changed, 663 insertions(+) create mode 100644 experiments/ontologies/scads-papers.owl.ttl create mode 100644 experiments/ontologies/scads-papers.ttl create mode 100644 experiments/ontologies/src/onto_chat.py create mode 100644 experiments/ontologies/src/onto_diff.py diff --git a/experiments/ontologies/scads-papers.owl.ttl b/experiments/ontologies/scads-papers.owl.ttl new file mode 100644 index 0000000..87c4280 --- /dev/null +++ b/experiments/ontologies/scads-papers.owl.ttl @@ -0,0 +1,221 @@ +@prefix : . +@prefix owl: . +@prefix rdfs: . + +######## +# Classes +######## + +:ScientificPaper a owl:Class . + +:ContentUnit a owl:Class . +:RhetoricalUnit a owl:Class ; rdfs:subClassOf :ContentUnit . +:ScientificContribution a owl:Class ; rdfs:subClassOf :ContentUnit . + +:ResearchProblem a owl:Class ; rdfs:subClassOf :ScientificContribution . +:ResearchQuestion a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Motivation a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Goal a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Hypothesis a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Claim a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Method a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Material a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Dataset a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Experiment a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Model a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Observation a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Result a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Conclusion a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Limitation a owl:Class ; rdfs:subClassOf :ScientificContribution . +:FutureWork a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Evidence a owl:Class ; rdfs:subClassOf :ScientificContribution . +:RelatedWorkStatement a owl:Class ; rdfs:subClassOf :ScientificContribution . +:Concept a owl:Class . +:Variable a owl:Class . +:Metric a owl:Class . + +######## +# Paper -> content +######## + +:hasContentUnit a owl:ObjectProperty ; + rdfs:domain :ScientificPaper ; + rdfs:range :ContentUnit . + +:hasProblem a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :ResearchProblem . + +:hasResearchQuestion a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :ResearchQuestion . + +:hasMotivation a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Motivation . + +:hasGoal a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Goal . + +:hasHypothesis a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Hypothesis . + +:hasClaim a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Claim . + +:hasMethod a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Method . + +:hasMaterial a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Material . + +:hasDataset a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Dataset . + +:hasExperiment a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Experiment . + +:hasModel a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Model . + +:hasObservation a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Observation . + +:hasResult a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Result . + +:hasConclusion a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Conclusion . + +:hasLimitation a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :Limitation . + +:hasFutureWork a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :FutureWork . + +:hasRelatedWorkStatement a owl:ObjectProperty ; + rdfs:subPropertyOf :hasContentUnit ; + rdfs:domain :ScientificPaper ; + rdfs:range :RelatedWorkStatement . + +######## +# Internal semantics +######## + +:addressesProblem a owl:ObjectProperty ; + rdfs:domain :Method ; + rdfs:range :ResearchProblem . + +:investigatesQuestion a owl:ObjectProperty ; + rdfs:domain :Experiment ; + rdfs:range :ResearchQuestion . + +:testsHypothesis a owl:ObjectProperty ; + rdfs:domain :Experiment ; + rdfs:range :Hypothesis . + +:usesMethod a owl:ObjectProperty ; + rdfs:domain :Experiment ; + rdfs:range :Method . + +:usesMaterial a owl:ObjectProperty ; + rdfs:domain :Experiment ; + rdfs:range :Material . + +:usesDataset a owl:ObjectProperty ; + rdfs:domain :Experiment ; + rdfs:range :Dataset . + +:studiesConcept a owl:ObjectProperty ; + rdfs:domain :ScientificContribution ; + rdfs:range :Concept . + +:hasVariable a owl:ObjectProperty ; + rdfs:domain :Experiment ; + rdfs:range :Variable . + +:usesMetric a owl:ObjectProperty ; + rdfs:domain :Result ; + rdfs:range :Metric . + +:producesObservation a owl:ObjectProperty ; + rdfs:domain :Experiment ; + rdfs:range :Observation . + +:supportsClaim a owl:ObjectProperty ; + rdfs:domain :Evidence ; + rdfs:range :Claim . + +:reportsEvidence a owl:ObjectProperty ; + rdfs:domain :Result ; + rdfs:range :Evidence . + +:derivedFromObservation a owl:ObjectProperty ; + rdfs:domain :Result ; + rdfs:range :Observation . + +:supports a owl:ObjectProperty ; + rdfs:domain :ScientificContribution ; + rdfs:range :ScientificContribution . + +:contradicts a owl:ObjectProperty ; + rdfs:domain :ScientificContribution ; + rdfs:range :ScientificContribution . + +:extends a owl:ObjectProperty ; + rdfs:domain :ScientificContribution ; + rdfs:range :ScientificContribution . + +:motivates a owl:ObjectProperty ; + rdfs:domain :Motivation ; + rdfs:range :Goal . + +:answers a owl:ObjectProperty ; + rdfs:domain :Conclusion ; + rdfs:range :ResearchQuestion . + +:basedOn a owl:ObjectProperty ; + rdfs:domain :Conclusion ; + rdfs:range :Result . + +:hasLimitationOn a owl:ObjectProperty ; + rdfs:domain :Limitation ; + rdfs:range :Method . + +######## +# Optional rhetorical typing +######## + +:IntroductionUnit a owl:Class ; rdfs:subClassOf :RhetoricalUnit . +:MethodsUnit a owl:Class ; rdfs:subClassOf :RhetoricalUnit . +:ResultsUnit a owl:Class ; rdfs:subClassOf :RhetoricalUnit . +:DiscussionUnit a owl:Class ; rdfs:subClassOf :RhetoricalUnit . diff --git a/experiments/ontologies/scads-papers.ttl b/experiments/ontologies/scads-papers.ttl new file mode 100644 index 0000000..e18623c --- /dev/null +++ b/experiments/ontologies/scads-papers.ttl @@ -0,0 +1,38 @@ +@prefix : . + +:paper1 a :ScientificPaper ; + :hasProblem :problem1 ; + :hasGoal :goal1 ; + :hasMethod :method1 ; + :hasExperiment :exp1 ; + :hasObservation :obs1 ; + :hasResult :result1 ; + :hasClaim :claim1 ; + :hasConclusion :concl1 . + +:problem1 a :ResearchProblem . +:goal1 a :Goal . +:method1 a :Method ; + :addressesProblem :problem1 . + +:exp1 a :Experiment ; + :usesMethod :method1 ; + :testsHypothesis :hyp1 ; + :producesObservation :obs1 . + +:hyp1 a :Hypothesis . +:obs1 a :Observation . + +:result1 a :Result ; + :derivedFromObservation :obs1 . + +:evidence1 a :Evidence ; + :supportsClaim :claim1 . + +:result1 :reportsEvidence :evidence1 . + +:claim1 a :Claim ; + :supports :goal1 . + +:concl1 a :Conclusion ; + :basedOn :result1 . diff --git a/experiments/ontologies/src/onto_chat.py b/experiments/ontologies/src/onto_chat.py new file mode 100644 index 0000000..a4c5b58 --- /dev/null +++ b/experiments/ontologies/src/onto_chat.py @@ -0,0 +1,404 @@ +"""Streamlit ontology chat prototype. + +Run: + uv run streamlit run experiments/ontologies/src/onto_chat.py +""" + +from __future__ import annotations + +from dataclasses import dataclass +import importlib +import os +import re +from textwrap import dedent + +import streamlit as st +import streamlit.components.v1 as components +from rdflib import Graph, RDF, RDFS, URIRef +from rdflib.namespace import OWL + + +EXAMPLE_OWL = dedent( + """\ + @prefix ex: . + @prefix rdf: . + @prefix rdfs: . + @prefix owl: . + + ex:Person a owl:Class . + ex:Company a owl:Class . + ex:Project a owl:Class . + + ex:worksFor a owl:ObjectProperty ; + rdfs:domain ex:Person ; + rdfs:range ex:Company . + + ex:worksOn a owl:ObjectProperty ; + rdfs:domain ex:Person ; + rdfs:range ex:Project . + """ +) + +DEFAULT_OPENAI_MODEL = "gpt-4o-mini" +KNOWN_PREFIXES = { + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "owl": "http://www.w3.org/2002/07/owl#", + "xsd": "http://www.w3.org/2001/XMLSchema#", +} + + +@dataclass +class OntologySchema: + classes: list[str] + object_edges: list[tuple[str, str, str]] + datatype_edges: list[tuple[str, str, str]] + + +def short_name(uri: URIRef) -> str: + """Return a compact local name for URI nodes.""" + text = str(uri) + if "#" in text: + return text.rsplit("#", maxsplit=1)[-1] + if "/" in text: + return text.rstrip("/").rsplit("/", maxsplit=1)[-1] + return text + + +def parse_graph(raw_text: str, rdf_format: str) -> Graph: + """Parse ontology text into an RDF graph.""" + graph = Graph() + graph.parse(data=raw_text, format=rdf_format) + return graph + + +def extract_schema(graph: Graph) -> OntologySchema: + """Extract classes and property relations from graph.""" + classes: set[str] = set() + object_edges: list[tuple[str, str, str]] = [] + datatype_edges: list[tuple[str, str, str]] = [] + + for cls in graph.subjects(RDF.type, OWL.Class): + if isinstance(cls, URIRef): + classes.add(short_name(cls)) + for cls in graph.subjects(RDF.type, RDFS.Class): + if isinstance(cls, URIRef): + classes.add(short_name(cls)) + + for prop in graph.subjects(RDF.type, OWL.ObjectProperty): + if not isinstance(prop, URIRef): + continue + prop_name = short_name(prop) + domains = [d for d in graph.objects(prop, RDFS.domain) if isinstance(d, URIRef)] + ranges = [r for r in graph.objects(prop, RDFS.range) if isinstance(r, URIRef)] + for domain in domains or [URIRef("UnknownDomain")]: + for rng in ranges or [URIRef("UnknownRange")]: + src, dst = short_name(domain), short_name(rng) + classes.update([src, dst]) + object_edges.append((src, prop_name, dst)) + + for prop in graph.subjects(RDF.type, OWL.DatatypeProperty): + if not isinstance(prop, URIRef): + continue + prop_name = short_name(prop) + domains = [d for d in graph.objects(prop, RDFS.domain) if isinstance(d, URIRef)] + ranges = [r for r in graph.objects(prop, RDFS.range) if isinstance(r, URIRef)] + for domain in domains or [URIRef("UnknownDomain")]: + for rng in ranges or [URIRef("Literal")]: + src, dst = short_name(domain), short_name(rng) + classes.add(src) + datatype_edges.append((src, prop_name, dst)) + + return OntologySchema( + classes=sorted(classes), + object_edges=object_edges, + datatype_edges=datatype_edges, + ) + + +def to_mermaid(schema: OntologySchema) -> str: + """Serialize ontology schema as Mermaid classDiagram.""" + lines = ["classDiagram"] + for cls_name in schema.classes: + lines.append(f" class {cls_name}") + for src, rel, dst in schema.object_edges: + lines.append(f" {src} --> {dst} : {rel}") + for src, rel, dst in schema.datatype_edges: + lines.append(f" {src} : {rel} -> {dst}") + return "\n".join(lines) + + +def render_mermaid(mermaid_text: str) -> None: + """Render Mermaid diagram in Streamlit via embedded HTML.""" + escaped = ( + mermaid_text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + ) + html = f""" +
{escaped}
+ + + """ + components.html(html, height=500, scrolling=True) + + +def draft_llm_prompt(user_request: str, ontology_text: str, rdf_format: str) -> str: + """Build a prompt for a future LLM integration.""" + return dedent( + f"""\ + You are editing an OWL ontology. + + Task: + {user_request} + + Requirements: + - Return only ontology text in {rdf_format} format. + - Preserve existing prefixes when possible. + - Declare all prefixes you use (especially xsd when using xsd:* datatypes). + - Keep edits minimal and valid. + - Do not include markdown fences. + + Current ontology: + {ontology_text} + """ + ) + + +def strip_markdown_fences(text: str) -> str: + """Remove markdown code fences if model returns them.""" + cleaned = text.strip() + if cleaned.startswith("```") and cleaned.endswith("```"): + lines = cleaned.splitlines() + if len(lines) >= 2: + return "\n".join(lines[1:-1]).strip() + return cleaned + + +def extract_declared_prefixes(text: str) -> set[str]: + """Extract declared prefixes from Turtle/N3 text.""" + return set(re.findall(r"@prefix\s+([A-Za-z][\w\-]*)\s*:", text)) + + +def extract_used_prefixes(text: str) -> set[str]: + """Extract prefixed terms used in Turtle/N3 text.""" + matches = re.findall(r"(? tuple[str, list[str]]: + """Inject known prefix declarations when terms use undeclared prefixes.""" + declared = extract_declared_prefixes(text) + used = extract_used_prefixes(text) + missing = sorted((used - declared) & set(KNOWN_PREFIXES)) + if not missing: + return text, [] + + injections = [f"@prefix {p}: <{KNOWN_PREFIXES[p]}> ." for p in missing] + updated = "\n".join(injections) + "\n" + text.lstrip() + return updated, missing + + +def validate_and_normalize_ontology(raw_text: str, rdf_format: str) -> tuple[str, list[str]]: + """Normalize and validate returned ontology text.""" + normalized = raw_text.strip() + added_prefixes: list[str] = [] + if rdf_format in {"turtle", "n3"}: + normalized, added_prefixes = inject_missing_known_prefixes(normalized) + parse_graph(normalized, rdf_format) + return normalized, added_prefixes + + +def request_ontology_edit(prompt: str, model: str) -> str: + """Call OpenAI and return ontology text.""" + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise RuntimeError("OPENAI_API_KEY is not set.") + + try: + openai_module = importlib.import_module("openai") + openai_client = getattr(openai_module, "OpenAI") + except Exception as exc: # noqa: BLE001 + raise RuntimeError( + "The 'openai' package is required. Install it with: uv add openai" + ) from exc + + client = openai_client(api_key=api_key) + response = client.chat.completions.create( + model=model, + temperature=0, + messages=[ + { + "role": "system", + "content": ( + "You edit OWL ontologies. Return only ontology text in the requested " + "serialization format. Do not add markdown." + ), + }, + {"role": "user", "content": prompt}, + ], + ) + content = response.choices[0].message.content or "" + if not content.strip(): + raise RuntimeError("OpenAI returned an empty response.") + return strip_markdown_fences(content) + + +def request_ontology_syntax_fix( + ontology_text: str, + rdf_format: str, + parse_error: Exception, + model: str, +) -> str: + """Ask OpenAI for a syntax-only repair of ontology text.""" + prompt = dedent( + f"""\ + Fix the syntax of this ontology serialization. + + Requirements: + - Return only ontology text in {rdf_format}. + - Preserve meaning; only fix syntax/prefix issues. + - Ensure all used prefixes are declared. + - Do not include markdown fences. + + Parser error: + {parse_error} + + Ontology text: + {ontology_text} + """ + ) + return request_ontology_edit(prompt=prompt, model=model) + + +def init_state() -> None: + """Initialize app session state keys.""" + st.session_state.setdefault("ontology_text", EXAMPLE_OWL) + st.session_state.setdefault("rdf_format", "turtle") + st.session_state.setdefault("messages", []) + st.session_state.setdefault("last_llm_prompt", "") + st.session_state.setdefault("last_llm_response", "") + st.session_state.setdefault("last_normalized_response", "") + st.session_state.setdefault("openai_model", DEFAULT_OPENAI_MODEL) + + +def main() -> None: + st.set_page_config(page_title="Ontology Chat Draft", layout="wide") + st.title("Ontology Chat + Mermaid (Draft)") + st.caption("Prototype UI for OWL editing with chat-driven change requests.") + + init_state() + + left_col, right_col = st.columns([1, 1], gap="large") + + with left_col: + st.subheader("Ontology Text") + st.session_state.rdf_format = st.selectbox( + "RDF format", + options=["turtle", "xml", "nt", "n3"], + index=["turtle", "xml", "nt", "n3"].index(st.session_state.rdf_format), + ) + st.session_state.openai_model = st.text_input( + "OpenAI model", + value=st.session_state.openai_model, + help="Requires OPENAI_API_KEY in environment.", + ) + st.session_state.ontology_text = st.text_area( + "Edit ontology", + value=st.session_state.ontology_text, + height=340, + ) + + st.subheader("Chat") + for msg in st.session_state.messages: + with st.chat_message(msg["role"]): + st.markdown(msg["content"]) + + user_request = st.chat_input("Describe ontology change...") + if user_request: + st.session_state.messages.append({"role": "user", "content": user_request}) + prompt = draft_llm_prompt( + user_request=user_request, + ontology_text=st.session_state.ontology_text, + rdf_format=st.session_state.rdf_format, + ) + st.session_state.last_llm_prompt = prompt + try: + with st.spinner("Requesting ontology update from OpenAI..."): + model_name = st.session_state.openai_model.strip() or DEFAULT_OPENAI_MODEL + edited_ontology = request_ontology_edit( + prompt=prompt, + model=model_name, + ) + st.session_state.last_llm_response = edited_ontology + try: + normalized_ontology, added_prefixes = validate_and_normalize_ontology( + edited_ontology, st.session_state.rdf_format + ) + except Exception as parse_exc: # noqa: BLE001 + with st.spinner("Attempting syntax repair..."): + repaired = request_ontology_syntax_fix( + ontology_text=edited_ontology, + rdf_format=st.session_state.rdf_format, + parse_error=parse_exc, + model=model_name, + ) + st.session_state.last_llm_response = repaired + normalized_ontology, added_prefixes = validate_and_normalize_ontology( + repaired, st.session_state.rdf_format + ) + + st.session_state.ontology_text = normalized_ontology + st.session_state.last_normalized_response = normalized_ontology + prefix_note = "" + if added_prefixes: + prefix_note = f" Added missing prefixes: {', '.join(added_prefixes)}." + st.session_state.messages.append( + { + "role": "assistant", + "content": ( + "Applied OpenAI ontology update and refreshed Mermaid diagram." + f"{prefix_note}" + ), + } + ) + except Exception as exc: # noqa: BLE001 + st.session_state.messages.append( + { + "role": "assistant", + "content": ( + "OpenAI request failed after validation/repair attempts: " + f"{exc}" + ), + } + ) + st.rerun() + + with st.expander("Last drafted LLM prompt", expanded=False): + st.code(st.session_state.last_llm_prompt or "No prompt drafted yet.", language="text") + with st.expander("Last OpenAI response", expanded=False): + st.code(st.session_state.last_llm_response or "No model response yet.", language="text") + with st.expander("Last normalized ontology", expanded=False): + st.code( + st.session_state.last_normalized_response or "No normalized ontology yet.", + language="text", + ) + + with right_col: + st.subheader("Mermaid Render") + try: + graph = parse_graph(st.session_state.ontology_text, st.session_state.rdf_format) + schema = extract_schema(graph) + mermaid = to_mermaid(schema) + render_mermaid(mermaid) + with st.expander("Mermaid source", expanded=False): + st.code(mermaid, language="text") + except Exception as exc: # noqa: BLE001 + st.error(f"Could not parse ontology: {exc}") + st.info("Check RDF format and ontology syntax in the left panel.") + + +if __name__ == "__main__": + main() diff --git a/experiments/ontologies/src/onto_diff.py b/experiments/ontologies/src/onto_diff.py new file mode 100644 index 0000000..e69de29 From 3b3d408a3e0617de3b2d31a75a521d755b69d832 Mon Sep 17 00:00:00 2001 From: Marvin Date: Sun, 22 Mar 2026 21:54:12 +0100 Subject: [PATCH 03/42] stash --- src/kgpipe/common/annotations.py | 2 +- src/kgpipe/common/definitions.py | 83 +---------- src/kgpipe/common/models.py | 63 --------- src/kgpipe/common/registry.py | 60 ++++---- src/kgpipe/common/systemgraph.py | 230 +++++++++++++++++-------------- src/kgpipe_view/kgpipe.owl.ttl | 94 +++++++++---- 6 files changed, 223 insertions(+), 309 deletions(-) diff --git a/src/kgpipe/common/annotations.py b/src/kgpipe/common/annotations.py index ceb24b1..3462262 100644 --- a/src/kgpipe/common/annotations.py +++ b/src/kgpipe/common/annotations.py @@ -11,7 +11,7 @@ def kg_class(description: str = ""): as a KG entity (type/Class node) once at import time. """ def decorator(cls): - print("kg_class decorator called for class: ", cls.__name__) + # print("kg_class decorator called for class: ", cls.__name__) # add owl class props = [] if description: diff --git a/src/kgpipe/common/definitions.py b/src/kgpipe/common/definitions.py index e14c4e8..7d86878 100644 --- a/src/kgpipe/common/definitions.py +++ b/src/kgpipe/common/definitions.py @@ -32,8 +32,7 @@ class KGPIPE_NS(DefinedNamespace): Metric = _NS["Metric"] MetricRun = _NS["MetricRun"] - -# Data # +# Entities # class DataHandle(BaseModel): """ @@ -53,85 +52,6 @@ class DataHandle(BaseModel): hash: Optional[str] = None size: Optional[int] = None -# Task # - -# # TODO describing entity vs entity with used values for the task -# class TaskConfiguration(BaseModel): -# key: str -# value: Any - -# class Task(BaseModel): -# """ -# A function that implements a task in a pipeline - -# name: paris_rdf_matcher -# type: entity_resolution -# description: "PARIS java implementation to match two RDF files, producing CSV files..." -# input: [any_rdf, any_rdf] -# output: [any_csv] -# """ -# name: str -# type: str -# description: Optional[str] = None -# input: List[schema_format] -# output: List[schema_format] - -# class TaskResult(BaseModel): -# """ -# The result of a task execution including configuration variables -# """ -# task: Task -# config: Dict[str, Any] -# input: List[DataHandle] -# output: List[DataHandle] -# status: str -# duration: float - -# # Evaluation # - -# class Eval(BaseModel): -# """ -# A function that evaluates data produced by tasks -# """ -# name: str -# type: str -# description: Optional[str] = None -# input: List[schema_format] - -# class EvalResult(BaseModel):# -# """ -# Result of an evaluation function -# """ -# eval: Eval -# config: Dict[str, Any] -# input: List[DataHandle] -# output: Dict[str, Any] -# status: str -# duration: float - -# # Pipeline # - -# class Pipeline(BaseModel): -# """ -# The plan of a pipeline -# """ -# tasks: List[Task] -# input: List[schema_format] -# output: List[schema_format] -# pokemon -# class PipelineResult(BaseModel): -# """ -# Result of a pipeline execution -# """ -# task_results: List[TaskResult] -# eval_results: List[EvalResult] -# input: List[DataHandle] -# output: List[DataHandle] -# status: str -# duration: float - -# new changes # - TaskEntityId = KGId class TaskEntity(BaseModel): name: str @@ -189,6 +109,7 @@ class TaskRunEntity(BaseModel): usesImplementation: ImplementationEntityId hasParameterBinding: List[ParameterBindingId] +# Entity representing a task dag (not the implementation) # class PipelineDefinitionEntity(BaseModel): # """ # The definition of a pipeline diff --git a/src/kgpipe/common/models.py b/src/kgpipe/common/models.py index b758f28..06a56ec 100644 --- a/src/kgpipe/common/models.py +++ b/src/kgpipe/common/models.py @@ -18,66 +18,3 @@ __all__ = [ "Data", "DataFormat", "DynamicFormat", "DataSet", "FormatRegistry", "KgTask", "KgTaskReport", "KgPipe", "KgPipePlan", "KgPipePlanStep", "KgStageReport", "Metric", "EvaluationReport", "KG", "TaskInput", "TaskOutput" ] - -# TODO remove this for next release -# @dataclass -# class KG: -# """Represents a knowledge graph.""" -# id: str -# name: str -# path: Path -# format: Format -# triple_count: Optional[int] = None -# entity_count: Optional[int] = None -# description: Optional[str] = None -# metadata: Dict[str, Any] = field(default_factory=dict) -# graph: Optional[Graph] = None -# data_graph: Optional[Graph] = None -# ontology_graph: Optional[Graph] = None -# plan: Optional[KgPipePlan] = None - -# def __post_init__(self): -# if not self.id: -# self.id = str(uuid.uuid4()) -# if isinstance(self.path, str): -# self.path = Path(self.path) -# if not self.name: -# raise ValueError("KG name cannot be empty") - -# def get_graph(self) -> Graph: -# if self.graph is None: -# tmp = Graph().parse(self.path) -# graph = Graph() -# for s, p, o in tmp: -# if (str(p) != str(SKOS.altLabel)): -# graph.add((s, p, o)) -# self.graph = graph -# return self.graph - -# def get_data_graph(self) -> Graph: -# return Graph() - -# def get_ontology_graph(self) -> Graph: -# # TODO derive from graph -# if self.ontology_graph is None: -# self.ontology_graph = Graph() -# return self.ontology_graph - -# def set_ontology_graph(self, graph: Graph) -> None: -# print(f"Setting ontology graph with {len(graph)} triples") -# self.ontology_graph = graph - -# def exists(self) -> bool: -# """Check if the KG file exists.""" -# return self.path.exists() - -# def __str__(self) -> str: -# return f"KG({self.name}, {self.path}, {self.format.value})" - - - - - -# # Backward compatibility aliases -# Task = KgTask -# Pipeline = KgPipe \ No newline at end of file diff --git a/src/kgpipe/common/registry.py b/src/kgpipe/common/registry.py index 49f8359..d0243e3 100644 --- a/src/kgpipe/common/registry.py +++ b/src/kgpipe/common/registry.py @@ -1,6 +1,6 @@ # global Registry, entry-point discovery -from typing import Any, Callable +from typing import Any, Callable, List, Dict from kgpipe.common.models import KgTask, DataFormat from kgpipe.common.systemgraph import PipeKG from kgpipe.common.definitions import MetricEntity @@ -8,16 +8,15 @@ # TODO add also to system graph - - - class Registry: """ - Holds functions and python objects + Holds functions and python objects mappings KGpipe system graph """ _registry: dict[str, Any] = {} + # Generic # + @classmethod def register(cls, kind: str): def decorator(t): @@ -25,6 +24,25 @@ def decorator(t): return t return decorator + @classmethod + def get(cls, kind: str, name: str): + return cls._registry[f"{kind}:{name}"] + + @classmethod + def list(cls, kind: str): + """List all registered items of a specific kind.""" + items = [] + for key, value in cls._registry.items(): + if key.startswith(f"{kind}:"): + items.append(value) + return items + + @classmethod + def list_all(cls): + return cls._registry + + # Metric # + @classmethod def metric(cls): def decorator(t): @@ -38,13 +56,15 @@ def decorator(t): return t return decorator + # Task # + @classmethod def task( cls, - input_spec: dict[str, DataFormat], - output_spec: dict[str, DataFormat], + input_spec: Dict[str, DataFormat], + output_spec: Dict[str, DataFormat], description: str | None = None, - category: list[str] = [], + category: List[str] = [], config_spec: ConfigurationDefinition | None = None ) -> Callable[[Callable], KgTask]: def decorator(t): @@ -54,30 +74,6 @@ def decorator(t): return task return decorator - # @classmethod - # def pipeline(cls, tasks: list[KgTask], input: Data, output: Data): - # pipeline = KgPipe(tasks, input, output) - # cls._registry[f"pipeline:{pipeline.__name__.lower()}"] = pipeline - # PipeKG.add_pipeline(pipeline) - # return pipeline - - @classmethod - def get(cls, kind: str, name: str): - return cls._registry[f"{kind}:{name}"] - @classmethod def get_task(cls, name: str) -> KgTask: return cls._registry[f"task:{name}"] - - @classmethod - def list(cls, kind: str): - """List all registered items of a specific kind.""" - items = [] - for key, value in cls._registry.items(): - if key.startswith(f"{kind}:"): - items.append(value) - return items - - @classmethod - def list_all(cls): - return cls._registry \ No newline at end of file diff --git a/src/kgpipe/common/systemgraph.py b/src/kgpipe/common/systemgraph.py index 2e9e756..9c9f95a 100644 --- a/src/kgpipe/common/systemgraph.py +++ b/src/kgpipe/common/systemgraph.py @@ -13,7 +13,8 @@ from kgcore.model.rdf.rdf_base import RDFBaseModel from kgpipe.common.definitions import ( - TaskEntity, TaskRunEntity, PipelineEntity, PipelineRunEntity, ImplementationEntity, MetricEntity, MetricRunEntity + TaskEntity, TaskRunEntity, PipelineEntity, PipelineRunEntity, ImplementationEntity, MetricEntity, MetricRunEntity, + MethodEntity, ToolEntity, ) from kgpipe.common.config import load_config from kgpipe.common.util import encode_string @@ -51,7 +52,7 @@ class PipeKG: It is used to store the entities and relations of the KGpipe framework. """ - # cached_implementations: Dict[str, KGEntity] = {} + ### Core Layer Entities ### @staticmethod def add_task(task: "KgTask"): @@ -70,43 +71,8 @@ def add_task(task: "KgTask"): "format": output_format, }) SYS_KG.create_relation(type="output", source=task_entity.id, target=output_entity.id) - + @staticmethod - def _prop_value(properties: List[KGProperty], *keys: str) -> Any: - """Find a property value by exact key or key suffix.""" - for prop in properties: - if prop.key in keys: - return prop.value - for prop in properties: - for key in keys: - if prop.key.endswith(key): - return prop.value - return None - - @staticmethod - def _to_list(value: Any) -> List[str]: - """Normalize KG property values to list[str].""" - if value is None: - return [] - if isinstance(value, list): - return [str(v) for v in value] - if isinstance(value, tuple): - return [str(v) for v in value] - if isinstance(value, str): - text = value.strip() - if not text: - return [] - # Stored literals may contain Python-list string repr. - if text.startswith("[") and text.endswith("]"): - try: - parsed = ast.literal_eval(text) - except (ValueError, SyntaxError): - return [text] - if isinstance(parsed, list): - return [str(v) for v in parsed] - return [text] - return [str(value)] - def list_taskImplementations(self) -> List[ImplementationEntity]: entities = SYS_KG.find_entities(types=[config.ONTOLOGY_PREFIX + "Implementation"]) implementations: List[ImplementationEntity] = [] @@ -156,32 +122,44 @@ def get_property_values(properties: list[KGProperty], key: str) -> list[str]: return implementations - def list_tasks(self) -> List[ImplementationEntity]: - """Backward-compatible alias used by existing UI code.""" - return self.list_taskImplementations() + @staticmethod + def add_method(method: MethodEntity): pass + @staticmethod + def find_method(name: str) -> MethodEntity: pass - # @staticmethod - # def add_task_result(task_result: TaskResult): - # SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"TaskRun"], properties={ - # "task": task_result.task, - # "config": task_result.config, - # "input": task_result.input, - # "output": task_result.output, - # "status": task_result.status, - # "duration": task_result.duration, - # }) + @staticmethod + def add_tool(tool: ToolEntity): pass + + @staticmethod + def find_tool(name: str) -> ToolEntity: pass + + @staticmethod + def find_implementation(): pass - # @staticmethod - # def add_task_run(task_run: "KgTaskReport"): - # SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"TaskReport"], properties={ - # "task": task_run.task_name, - # "input": [data.path for data in task_run.inputs], - # "output": [data.path for data in task_run.outputs], - # "status": task_run.status, - # "duration": task_run.duration, - # "error": task_run.error, - # }) + @staticmethod + def add_implementation(implementation: ImplementationEntity): + SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"Implementation"], properties={ + "name": implementation.name, + "usesTool": implementation.usesTool, + "implementsMethod": implementation.implementsMethod, + "interface": implementation.interface, + + }) + + @staticmethod + def find_implementation(name: str) -> KGEntity: + return SYS_KG.read_entity(id=config.PIPEKG_PREFIX+name, types=[config.ONTOLOGY_PREFIX+"Implementation"])[0] + + ### Data Layer Entities ### + def add_data_artifact(): pass + def add_data_artifact_type(): pass + def add_data_artifact_spec(): pass + def find_data_artifact(): pass + def find_data_artifact_type(): pass + def find_data_artifact_spec(): pass + + ### Pipeline Layer Entities ### @staticmethod def add_pipeline(pipeline: PipelineEntity): @@ -191,14 +169,13 @@ def add_pipeline(pipeline: PipelineEntity): "output": pipeline.output, }) - # @staticmethod - # def add_pipeline_result(pipeline_result: PipelineResult): - # SYS_KG.create_entity(id=new_id(),types=["PipelineResult"], properties={ - # "task_results": pipeline_result.task_results, - # "eval_results": pipeline_result.eval_results, - # "input": pipeline_result.input, - # "output": pipeline_result.output, - # }) + def find_pipeline(): pass + def add_pipeline_step(): pass + def find_pipeline_step(): pass + def add_pipeline_definition(): pass + def find_pipeline_definition(): pass + + ### Evaluation Layer Entities ### @staticmethod def add_metric(metric: MetricEntity): @@ -211,30 +188,28 @@ def add_metric(metric: MetricEntity): }) @staticmethod - def add_metric_run(metric_run: MetricRunEntity): - metric_run_entity = SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"MetricRun"], properties={ - config.ONTOLOGY_PREFIX+"status": metric_run.status, - config.ONTOLOGY_PREFIX+"started_at": metric_run.started_at, - config.ONTOLOGY_PREFIX+"ended_at": metric_run.ended_at, - config.ONTOLOGY_PREFIX+"value": metric_run.value, - config.ONTOLOGY_PREFIX+"details": metric_run.details, - config.ONTOLOGY_PREFIX+"input": metric_run.input[0].uri, - }) - SYS_KG.create_relation(type=config.ONTOLOGY_PREFIX+"computedMetric", source=metric_run_entity.id, target=metric_run.computedMetric) + def find_metric(metric_name: str) -> MetricEntity: + pass - # @staticmethod - # def find_implementation_by_name(name: str) -> KGEntity: - # return SYS_KG.read_entity(id=config.PIPEKG_PREFIX+name, types=[config.ONTOLOGY_PREFIX+"Implementation"])[0] + ### Run Layer Entities ### + # def add_task_run(): pass + # def find_task_run(): pass + # def add_pipeline_run(): pass + # def find_pipeline_run(): pass + # def add_metric_run(): pass + # def find_metric_run(): pass @staticmethod - def add_implementation(implementation: ImplementationEntity): - SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"Implementation"], properties={ - "name": implementation.name, - "usesTool": implementation.usesTool, - "implementsMethod": implementation.implementsMethod, - "interface": implementation.interface, - - }) + def add_task_run(task_run: TaskRunEntity): + # SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"TaskReport"], properties={ + # "task": task_run.task_name, + # "input": [data.path for data in task_run.inputs], + # "output": [data.path for data in task_run.outputs], + # "status": task_run.status, + # "duration": task_run.duration, + # "error": task_run.error, + # }) + pass @staticmethod def add_pipeline_run(pipeline_run: PipelineRunEntity): @@ -258,27 +233,76 @@ def add_pipeline_run(pipeline_run: PipelineRunEntity): # return pipeline_run_entity + # @staticmethod + # def add_pipeline_result(pipeline_result: PipelineResult): + # SYS_KG.create_entity(id=new_id(),types=["PipelineResult"], properties={ + # "task_results": pipeline_result.task_results, + # "eval_results": pipeline_result.eval_results, + # "input": pipeline_result.input, + # "output": pipeline_result.output, + # }) + + @staticmethod + def add_metric_run(metric_run: MetricRunEntity): + metric_run_entity = SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"MetricRun"], properties={ + config.ONTOLOGY_PREFIX+"status": metric_run.status, + config.ONTOLOGY_PREFIX+"started_at": metric_run.started_at, + config.ONTOLOGY_PREFIX+"ended_at": metric_run.ended_at, + config.ONTOLOGY_PREFIX+"value": metric_run.value, + config.ONTOLOGY_PREFIX+"details": metric_run.details, + config.ONTOLOGY_PREFIX+"input": metric_run.input[0].uri, + }) + SYS_KG.create_relation(type=config.ONTOLOGY_PREFIX+"computedMetric", source=metric_run_entity.id, target=metric_run.computedMetric) + + ### Parameter Layer Entities ### + # def add_parameter(): pass + # def find_parameter(): pass + # def add_parameter_binding(): pass + # def find_parameter_binding(): pass + + ### Utility Functions ### + @staticmethod def sparql_construct(query: str): backend : RDFSparqlBackend = SYS_KG.backend result = backend.query_sparql(query) return result - -class MapperUtil(): - """ - Intermediate class to map the core classes to the definitions to the system graph. - Will be replaced in the future - """ - @staticmethod - def map_task(task: "KgTask") -> TaskEntity: - return TaskEntity( - name=task.name, - input=task.input, - output=task.output, - ) + def _prop_value(properties: List[KGProperty], *keys: str) -> Any: + """Find a property value by exact key or key suffix.""" + for prop in properties: + if prop.key in keys: + return prop.value + for prop in properties: + for key in keys: + if prop.key.endswith(key): + return prop.value + return None + @staticmethod + def _to_list(value: Any) -> List[str]: + """Normalize KG property values to list[str].""" + if value is None: + return [] + if isinstance(value, list): + return [str(v) for v in value] + if isinstance(value, tuple): + return [str(v) for v in value] + if isinstance(value, str): + text = value.strip() + if not text: + return [] + # Stored literals may contain Python-list string repr. + if text.startswith("[") and text.endswith("]"): + try: + parsed = ast.literal_eval(text) + except (ValueError, SyntaxError): + return [text] + if isinstance(parsed, list): + return [str(v) for v in parsed] + return [text] + return [str(value)] # def Track(_cls=None, *, with_timestamp: bool = False): # """ diff --git a/src/kgpipe_view/kgpipe.owl.ttl b/src/kgpipe_view/kgpipe.owl.ttl index aea0dbb..a05c816 100644 --- a/src/kgpipe_view/kgpipe.owl.ttl +++ b/src/kgpipe_view/kgpipe.owl.ttl @@ -29,9 +29,10 @@ :TaskRun a owl:Class, :RunLayer . :PipelineRun a owl:Class, :RunLayer . -:Artifact a owl:Class, :DataLayer . -:ArtifactType a owl:Class, :DataLayer . -:Schema a owl:Class, :DataLayer . +:DataArtifact a owl:Class, :DataLayer . +:DataDataArtifactSpec a owl:Class, :DataLayer . +:DataDataArtifactType a owl:Class, :DataLayer . +#:Schema a owl:Class, :DataLayer . :Parameter a owl:Class, :ParameterLayer . :ParameterBinding a owl:Class, :ParameterLayer . @@ -95,9 +96,9 @@ rdfs:domain :PipelineDefinition ; rdfs:range :Tool . -:hasSourceArtifact a owl:ObjectProperty ; +:hasSourceDataArtifact a owl:ObjectProperty ; rdfs:domain :PipelineDefinition ; - rdfs:range :Artifact . + rdfs:range :DataArtifact . ### Execution / runs :executesTask a owl:ObjectProperty ; @@ -121,31 +122,35 @@ rdfs:range :TaskRun . ### Data flow (runtime) -:hasInputArtifact a owl:ObjectProperty ; +:hasInputDataArtifact a owl:ObjectProperty ; rdfs:domain :TaskRun ; - rdfs:range :Artifact . + rdfs:range :DataArtifact . -:hasOutputArtifact a owl:ObjectProperty ; +:hasOutputDataArtifact a owl:ObjectProperty ; rdfs:domain :TaskRun ; - rdfs:range :Artifact . + rdfs:range :DataArtifact . ### Data flow typing (design-time) -:expectsInputType a owl:ObjectProperty ; +:expectsInputSpec a owl:ObjectProperty ; rdfs:domain :Implementation ; - rdfs:range :ArtifactType . + rdfs:range :DataDataArtifactSpec . -:producesOutputType a owl:ObjectProperty ; +:producesOutputSpec a owl:ObjectProperty ; rdfs:domain :Implementation ; - rdfs:range :ArtifactType . + rdfs:range :DataDataArtifactSpec . -### Artifact typing / schema -:hasArtifactType a owl:ObjectProperty ; - rdfs:domain :Artifact ; - rdfs:range :ArtifactType . +:requiresType a owl:ObjectProperty ; + rdfs:domain :DataDataArtifactSpec ; + rdfs:range :DataDataArtifactType . -:conformsToSchema a owl:ObjectProperty ; - rdfs:domain :Artifact ; - rdfs:range :Schema . +### DataArtifact typing / schema +:hasDataDataArtifactType a owl:ObjectProperty ; + rdfs:domain :DataArtifact ; + rdfs:range :DataDataArtifactType . + +#:conformsToSchema a owl:ObjectProperty ; +# rdfs:domain :DataArtifact ; +# rdfs:range :Schema . ### Parameters :hasParameter a owl:ObjectProperty ; @@ -165,6 +170,10 @@ ################################################################# ### Implementation +:implementationName a owl:DatatypeProperty ; + rdfs:domain :Implementation ; + rdfs:range xsd:string . + :commandTemplate a owl:DatatypeProperty ; rdfs:domain :Implementation ; rdfs:range xsd:string . @@ -177,11 +186,47 @@ rdfs:domain :Implementation ; rdfs:range xsd:string . +### Method +:methodName a owl:DatatypeProperty ; + rdfs:domain :Method ; + rdfs:range xsd:string . + ### Tool :toolVersion a owl:DatatypeProperty ; rdfs:domain :Tool ; rdfs:range xsd:string . +:toolName a owl:DatatypeProperty ; + rdfs:domain :Tool ; + rdfs:range xsd:string . + +:toolPage a owl:DatatypeProperty ; + rdfs:domain :Tool ; + rdfs:range xsd:string . + +:toolName a owl:DatatypeProperty ; + rdfs:domain :Tool ; + rdfs:range xsd:string . + +### DataArtifact +:location a owl:DatatypeProperty ; + rdfs:domain :DataArtifact ; + rdfs:range xsd:anyURI . + +### DataDataArtifactSpec +:dataType a owl:DatatypeProperty ; + rdfs:domain :DataDataArtifactSpec ; + rdfs:range xsd:string . + +### DataDataArtifactType +:dataFormat a owl:DatatypeProperty ; + rdfs:domain :DataDataArtifactType ; + rdfs:range xsd:string . + +:dataSchema a owl:DatatypeProperty ; + rdfs:domain :DataDataArtifactType ; + rdfs:range xsd:string . + ### Parameter :paramName a owl:DatatypeProperty ; rdfs:domain :Parameter ; @@ -238,12 +283,3 @@ rdfs:domain :PipelineRun ; rdfs:range xsd:string . -### Artifact -:location a owl:DatatypeProperty ; - rdfs:domain :Artifact ; - rdfs:range xsd:anyURI . - -### ArtifactType -:format a owl:DatatypeProperty ; - rdfs:domain :ArtifactType ; - rdfs:range xsd:string . \ No newline at end of file From b06cad5000177052c3d7bc00c28d3b67bdd76703 Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 26 Mar 2026 14:52:03 +0100 Subject: [PATCH 04/42] refactor(common): move graph/systemgraph into common.graph and reshape core models - Split graph-related code into src/kgpipe/common/graph/* (incl. systemgraph) - Drop legacy common/definitions.py + old common/systemgraph.py - Rework data formats/catalog and simplify model/data.py around BasicDataFormats/CustomDataFormats - Extend task/config models (e.g. KgTaskRun, config profile helpers) and export the new public surface via common/__init__.py --- src/kgpipe/common/__init__.py | 6 +- src/kgpipe/common/annotations.py | 88 ++++- src/kgpipe/common/definitions.py | 155 --------- src/kgpipe/common/graph/__init__.py | 0 src/kgpipe/common/graph/definitions.py | 286 +++++++++++++++++ src/kgpipe/common/graph/mapper.py | 213 +++++++++++++ src/kgpipe/common/graph/systemgraph.py | 352 ++++++++++++++++++++ src/kgpipe/common/model/__init__.py | 10 +- src/kgpipe/common/model/configuration.py | 37 ++- src/kgpipe/common/model/data.py | 245 ++------------ src/kgpipe/common/model/default_catalog.py | 210 +++++++++++- src/kgpipe/common/model/evaluation.py | 19 +- src/kgpipe/common/model/kg.py | 32 +- src/kgpipe/common/model/pipeline.py | 50 +-- src/kgpipe/common/model/task.py | 280 ++++++++++++---- src/kgpipe/common/models.py | 8 +- src/kgpipe/common/registry.py | 12 +- src/kgpipe/common/systemgraph.py | 354 --------------------- 18 files changed, 1450 insertions(+), 907 deletions(-) delete mode 100644 src/kgpipe/common/definitions.py create mode 100644 src/kgpipe/common/graph/__init__.py create mode 100644 src/kgpipe/common/graph/definitions.py create mode 100644 src/kgpipe/common/graph/mapper.py create mode 100644 src/kgpipe/common/graph/systemgraph.py delete mode 100644 src/kgpipe/common/systemgraph.py diff --git a/src/kgpipe/common/__init__.py b/src/kgpipe/common/__init__.py index 33cf414..b800851 100644 --- a/src/kgpipe/common/__init__.py +++ b/src/kgpipe/common/__init__.py @@ -25,8 +25,9 @@ def setup_logging(log_file='app.log', level=logging.DEBUG): # Call this once at the start of your application setup_logging() +from .annotations import trace_task_run from .models import ( - Data, DataFormat, KgTask, KgTaskReport, DynamicFormat, FormatRegistry, + Data, DataFormat, BasicDataFormats, CustomDataFormats, BasicTaskCategoryCatalog, KgTask, KgTaskReport, DataSet, KG, Metric, EvaluationReport, KgPipe, TaskInput, TaskOutput ) from .registry import Registry @@ -38,8 +39,9 @@ def setup_logging(log_file='app.log', level=logging.DEBUG): ) __all__ = [ - "Data", "DataFormat", "KgTask", "KgTaskReport", "DynamicFormat", "FormatRegistry", + "Data", "DataFormat", "BasicDataFormats", "CustomDataFormats", "BasicTaskCategoryCatalog", "KgTask", "KgTaskReport", "DataSet", "KG", "Stage", "Metric", "EvaluationReport", "KgPipe", "TaskInput", "TaskOutput", + "trace_task_run", "Registry", "get_docker_volume_bindings", "remap_data_path_for_container", "discover_entry_points", "get_registered_tasks", "get_registered_pipelines", diff --git a/src/kgpipe/common/annotations.py b/src/kgpipe/common/annotations.py index 3462262..0146571 100644 --- a/src/kgpipe/common/annotations.py +++ b/src/kgpipe/common/annotations.py @@ -1,5 +1,5 @@ from rdflib import OWL, RDFS -from kgpipe.common.systemgraph import SYS_KG +from kgpipe.common.graph.systemgraph import SYS_KG, PipeKG from kgcore.api import KGProperty from typing import get_origin, get_args, Union @@ -73,4 +73,88 @@ def decorator(cls): SYS_KG.create_relation(source=prop_et.id, target=class_et.id, type=str(RDFS.domain)) return cls - return decorator \ No newline at end of file + return decorator + + + +def trace_metric_run(): pass + + +def trace_task_run(obj): + """ + Mark a task (function or `KgTask`) so that its `.run()` persists a TaskRun in `PipeKG`. + + Works with either decorator order: + + ```python + @trace_task_run + @Registry.task(...) + def my_task(...): ... + + # or + @Registry.task(...) + @trace_task_run + def my_task(...): ... + ``` + """ + setattr(obj, "trace_task_run", True) + # TODO use logger print(f"trace_task_run decorator called for object: {obj.__name__}") + return obj + +def trace_pipeline_run(obj): + """ + Mark a pipeline (function or `KgPipeline`) so that its `.run()` persists a PipelineRun in `PipeKG`. + """ + setattr(obj, "trace_pipeline_run", True) + # TODO use logger print(f"trace_pipeline_run decorator called for object: {obj.__name__}") + return obj + + +# def Track(_cls=None, *, with_timestamp: bool = False): +# """ +# Use as: +# @Track +# @Track(with_timestamp=True) +# """ +# def decorator(cls): +# class Tracked(cls): # subclass the original class +# def __init__(self, *args: Any, **kwargs: Any): +# super().__init__(*args, **kwargs) + +# inst_id = f"{cls.__name__}:{uuid4().hex[:8]}" +# setattr(self, "_kg_id", inst_id) + +# if isinstance(self, BaseModel): +# props = self.model_dump() +# else: +# props = {k: v for k, v in vars(self).items() if not k.startswith("_")} + +# if with_timestamp: +# props["timestamp"] = datetime.now(timezone.utc).isoformat() + +# SYS_KG.create_entity([cls.__name__], id=inst_id, props=props) + +# Tracked.__name__ = cls.__name__ # optional cosmetics +# Tracked.__qualname__ = cls.__qualname__ +# Tracked.__doc__ = cls.__doc__ +# return Tracked + +# return decorator if _cls is None else decorator(_cls) + +# def kg_function(fn): +# @functools.wraps(fn) +# def wrapper(*args, **kwargs): +# result = fn(*args, **kwargs) +# call_id = f"{fn.__name__}:{uuid4().hex[:8]}" +# SYS_KG.create_entity( +# ["FunctionCall"], +# id=call_id, +# props={ +# "name": fn.__name__, +# # Be careful serializing args/kwargs; this is a toy example: +# "args": repr(args), +# "kwargs": repr(kwargs), +# }, +# ) +# return result +# return wrapper diff --git a/src/kgpipe/common/definitions.py b/src/kgpipe/common/definitions.py deleted file mode 100644 index 7d86878..0000000 --- a/src/kgpipe/common/definitions.py +++ /dev/null @@ -1,155 +0,0 @@ -from dataclasses import dataclass -from sys import implementation -from pydantic import BaseModel -from typing import Mapping, Optional, List, Dict, Any -from kgcore.api.kg import KGId - -from kgpipe.common.model.data import DataFormat - -# Types # - -type schema_format = str - -# Vocabulary # - -from rdflib.namespace import DefinedNamespace, Namespace - -class KGPIPE_NS(DefinedNamespace): - _fail = True - _NS = Namespace("http://github.com/ScaDS/kgpipe/") - Task = _NS["Task"] - TaskRun = _NS["TaskRun"] - Method = _NS["Method"] - Tool = _NS["Tool"] - Implementation = _NS["Implementation"] - Parameter = _NS["Parameter"] - ParameterBinding = _NS["ParameterBinding"] - Pipeline = _NS["Pipeline"] - PipelineRun = _NS["PipelineRun"] - Artifact = _NS["Artifact"] - ArtifactType = _NS["ArtifactType"] - Schema = _NS["Schema"] - Metric = _NS["Metric"] - MetricRun = _NS["MetricRun"] - -# Entities # - -class DataHandle(BaseModel): - """ - A handle to a data artifact - - uri: file://example.com/data.txt - type: any/text - timestamp: 2021-01-01 - version: 1.0.0 - hash: 1234567890 - size: 1000 - """ - uri: str - type: schema_format - timestamp: Optional[str] = None - version: Optional[str] = None - hash: Optional[str] = None - size: Optional[int] = None - -TaskEntityId = KGId -class TaskEntity(BaseModel): - name: str - hasSubtask: List[TaskEntityId] - -MethodEntityId = KGId -class MethodEntity(BaseModel): - name: str - realizesTask: List[TaskEntityId] - -ToolEntityId = KGId -class ToolEntity(BaseModel): - name: str - # supportsTasks: List[Task] - providesMethods: List[MethodEntityId] - -ParameterId = KGId -class ParameterEntity(BaseModel): - name: str - value: Any - type: str - description: Optional[str] = None - default_value: Optional[Any] = None - required: bool = False - allowed_values: Optional[List[Any]] = None - -ParameterBindingId = KGId -class ParameterBindingEntity(BaseModel): - value: Any - parameter: ParameterId - -ImplementationEntityId = KGId -class ImplementationEntity(BaseModel): - uri: Optional[str] = None - name: str - input_spec: List[str] - output_spec: List[str] - implementsMethod: List[MethodEntityId] - hasParameter: List[ParameterId] - usesTool: List[ToolEntityId] - - # interface: str # TODO: Interface - # hasParameter: Parameter - -TaskRunEntityId = KGId -class TaskRunEntity(BaseModel): - number: int - name: str - status: str - started_at: float - ended_at: float - input: List[DataHandle] - output: List[DataHandle] - executesTask: TaskEntityId - usesImplementation: ImplementationEntityId - hasParameterBinding: List[ParameterBindingId] - -# Entity representing a task dag (not the implementation) -# class PipelineDefinitionEntity(BaseModel): -# """ -# The definition of a pipeline -# """ -# placeholder: str -# #definesPipeline: Pipeline - -# TODO issue as the Graph has no ordering of the tasks -class PipelineEntity(BaseModel): - name: str - tasks: List[TaskEntityId] - input: List[DataHandle] - output: List[DataHandle] - -class PipelineRunEntity(BaseModel): - """ - The result of a pipeline execution - """ - name: str - status: str - started_at: float - ended_at: float - hasTaskRun: List[TaskRunEntity] - # usesPipelineDefinition: PipelineDefinition - # runsPipeline: Pipeline - -MetricEntityId = KGId -class MetricEntity(BaseModel): - name: str - description: Optional[str] = None - type: str - # output: List[schema_format] - # hasParameter: List[ParameterId] - -MetricRunEntityId = KGId -class MetricRunEntity(BaseModel): - status: str - started_at: float - ended_at: float - computedMetric: MetricEntityId - input: List[DataHandle] - value: float - details: str \ No newline at end of file diff --git a/src/kgpipe/common/graph/__init__.py b/src/kgpipe/common/graph/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/kgpipe/common/graph/definitions.py b/src/kgpipe/common/graph/definitions.py new file mode 100644 index 0000000..8889580 --- /dev/null +++ b/src/kgpipe/common/graph/definitions.py @@ -0,0 +1,286 @@ +from pydantic import BaseModel, ConfigDict +from typing import Optional, List, Any +from kgcore.api.kg import KGId + +# Types # + +type schema_format = str +type any_uri = str + +# Vocabulary # + +from rdflib.namespace import DefinedNamespace, Namespace + +class KGPIPE_NS(DefinedNamespace): + _fail = True + _NS = Namespace("http://github.com/ScaDS/kgpipe/") + + Task = _NS["Task"] + TaskRun = _NS["TaskRun"] + Method = _NS["Method"] + Tool = _NS["Tool"] + Implementation = _NS["Implementation"] + Parameter = _NS["Parameter"] + ParameterBinding = _NS["ParameterBinding"] + Pipeline = _NS["Pipeline"] + PipelineRun = _NS["PipelineRun"] + Artifact = _NS["Artifact"] + ArtifactType = _NS["ArtifactType"] + Schema = _NS["Schema"] + Metric = _NS["Metric"] + MetricRun = _NS["MetricRun"] + DataSpec = _NS["DataSpec"] + DataEntity = _NS["Data"] + DataType = _NS["DataType"] + ConfigSpec = _NS["ConfigSpec"] + ConfigBinding = _NS["ConfigBinding"] + + + status = _NS["status"] + started_at = _NS["started_at"] + ended_at = _NS["ended_at"] + schema = _NS["schema"] + format = _NS["format"] + name = _NS["name"] + partOfTask = _NS["partOfTask"] + hasSubtask = _NS["hasSubtask"] + description = _NS["description"] + + version = _NS["version"] + executesTask = _NS["executesTask"] + supportsTask = _NS["supportsTask"] + input = _NS["input"] + output = _NS["output"] + format = _NS["format"] + config_spec = _NS["config_spec"] + + timestamp = _NS["timestamp"] + version = _NS["version"] + hash = _NS["hash"] + size = _NS["size"] + location = _NS["location"] + data_type = _NS["data_type"] + + realisesTask = _NS["realisesTask"] + usesImplementation = _NS["usesImplementation"] + + homepage = _NS["homepage"] + implementsMethod = _NS["implementsMethod"] + usesTool = _NS["usesTool"] + hasParameter = _NS["hasParameter"] + + providesMethod = _NS["providesMethod"] + + key = _NS["key"] + alias_keys = _NS["alias_keys"] + datatype = _NS["datatype"] + required = _NS["required"] + default_value = _NS["default_value"] + allowed_values = _NS["allowed_values"] + minimum = _NS["minimum"] + maximum = _NS["maximum"] + unit = _NS["unit"] + value = _NS["value"] + binding = _NS["binding"] + + parameter = _NS["parameter"] + hasParameterBinding = _NS["hasParameterBinding"] + +# Entities # + +DataTypeEntityId = KGId +class DataTypeEntity(BaseModel): + model_config = ConfigDict(frozen=True) + ### object properties ### + format: str + data_schema: str + +DataEntityId = KGId +class DataEntity(BaseModel): + model_config = ConfigDict(frozen=True) + ### datatype properties ### + timestamp: Optional[str] = None + version: Optional[str] = None + hash: Optional[str] = None + size: Optional[int] = None + ### object properties ### + location: any_uri + data_type: DataTypeEntityId + +DataSpecEntityId = KGId +class DataSpecEntity(BaseModel): + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + name: str + ### object properties ### + data_type: DataTypeEntityId + +TaskEntityId = KGId +class TaskEntity(BaseModel): + model_config = ConfigDict(frozen=True) + name: str + description: Optional[str] = None + partOfTask: Optional[TaskEntityId] = None + +# TODO MethodEntityId = KGId +# TODO class MethodEntity(BaseModel): +# model_config = ConfigDict(frozen=True) +# name: str +# realizesTask: tuple[TaskEntityId, ...] + +ToolEntityId = KGId +class ToolEntity(BaseModel): + model_config = ConfigDict(frozen=True) + ### datatype properties ### + name: str + homepage: Optional[str] = None + ### object properties ### + # NOTE: these entities are used as `lru_cache` keys; must be hashable. + supportsTasks: tuple[TaskEntityId, ...] + # TODO providesMethods: tuple[MethodEntityId, ...] + +ParameterEntityId = KGId +class ParameterEntity(BaseModel): + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + key: str + # NOTE: these entities are used as `lru_cache` keys; must be hashable. + alias_keys: tuple[str, ...] + datatype: str + required: bool + default_value: str | int | float | bool + allowed_values: tuple[str | int | float | bool, ...] + # description: Optional[str] = None + # scope: Scope # (training/inference/io/resources) + # constraints + # minimum: Optional[float] = None + # maximum: Optional[float] = None + # unit: Optional[str] = None + +ParameterBindingEntityId = KGId +class ParameterBindingEntity(BaseModel): + value: Any + parameter: ParameterEntityId + +ConfigSpecEntityId = KGId +class ConfigSpecEntity(BaseModel): + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + name: str + ### object properties ### + # NOTE: these entities are used as `lru_cache` keys; must be hashable. + parameters: tuple[ParameterEntityId, ...] + +ConfigBindingEntityId = KGId +class ConfigBindingEntity(BaseModel): + name: Any + binding: tuple[ParameterBindingEntityId, ...] + +ImplementationEntityId = KGId +class ImplementationEntity(BaseModel): + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + name: str + version: str + ### object properties ### + input_spec: List[DataSpecEntityId] + output_spec: List[DataSpecEntityId] + realizesTask: List[TaskEntityId] + usesTool: List[ToolEntityId] + config_spec: Optional[ConfigSpecEntityId] = None + + # TODO implementsMethod: List[MethodEntityId] + # TODO interface: str + +TaskRunEntityId = KGId +class TaskRunEntity(BaseModel): + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + status: str + started_at: float + ended_at: float + ### object properties ### + input: List[DataEntityId] + output: List[DataEntityId] + # TODO executesTask: TaskEntityId + usesImplementation: ImplementationEntityId + hasConfigBinding: Optional[ConfigBindingEntityId] = None + +# Entity representing a task dag (not the implementation) +# class PipelineDefinitionEntity(BaseModel): +# """ +# The definition of a pipeline +# """ +# placeholder: str +# #definesPipeline: Pipeline + +PipelineStepEntityId = KGId +class PipelineStepEntity(BaseModel): + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + name: str + ### object properties ### + input: List[DataEntityId] + output: List[DataEntityId] + executesTask: TaskEntityId + +# TODO issue as the Graph has no ordering of the tasks +class PipelineEntity(BaseModel): + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + name: str + ### object properties ### + steps: List[PipelineStepEntityId] + firstStep: PipelineStepEntityId + lastStep: PipelineStepEntityId + input: List[DataEntityId] + output: List[DataEntityId] + +PipelineRunEntityId = KGId +class PipelineRunEntity(BaseModel): + """ + The result of a pipeline execution + """ + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + name: str + status: str + started_at: float + ended_at: float + ### object properties ### + hasTaskRun: List[TaskRunEntity] + # TODO usesPipelineDefinition: PipelineDefinition + # TODO runsPipeline: PipelineStepEntityId + +MetricEntityId = KGId +class MetricEntity(BaseModel): + model_config = ConfigDict(frozen=True) + ### datatype properties ### + name: str + description: Optional[str] = None + type: str # TODO should be an enum + ### object properties ### + # TODO output: List[schema_format] + # TODO hasParameter: List[ParameterId] + +MetricRunEntityId = KGId +class MetricRunEntity(BaseModel): + model_config = ConfigDict(frozen=True) + uri: Optional[str] = None + ### datatype properties ### + status: str + started_at: float + ended_at: float + value: float + details: str # TODO should be a dictionary + ### object properties ### + computedMetric: MetricEntityId + input: List[DataEntityId] diff --git a/src/kgpipe/common/graph/mapper.py b/src/kgpipe/common/graph/mapper.py new file mode 100644 index 0000000..037fd9e --- /dev/null +++ b/src/kgpipe/common/graph/mapper.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from kgpipe.common.config import config +from kgpipe.common.graph.systemgraph import PipeKG +from kgpipe.common.model.default_catalog import TaskCategory +from kgpipe.common.util import encode_string + +from kgpipe.common.graph.definitions import ( + DataEntity, + DataEntityId, + DataSpecEntity, + DataSpecEntityId, + DataTypeEntity, + DataTypeEntityId, + ImplementationEntity, + ImplementationEntityId, + PipelineRunEntity, + PipelineRunEntityId, + TaskEntity, + TaskEntityId, + TaskRunEntity, + TaskRunEntityId, + MetricRunEntity, + MetricRunEntityId, + MetricEntity, + MetricEntityId, + ParameterEntity, + ParameterEntityId, + ParameterBindingEntity, + ParameterBindingEntityId, + ConfigSpecEntity, + ConfigSpecEntityId, + ConfigBindingEntity, + ConfigBindingEntityId, +) + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from kgpipe.common.model import ( + DataFormat, + KgData, + KgTask, + KgTaskRun, + KgPipelineRun, + KgMetricRun, + KgMetric, + ConfigurationDefinition, + Parameter, + ConfigurationProfile, + ParameterBinding, + ) + from kgpipe.evaluation.base import MetricResult + +def task_to_entity(task: "TaskCategory") -> TaskEntityId: + """Map runtime task definition to a Task entity.""" + name = task + partOfTask = None + if isinstance(task, TaskCategory): + name = task.name + if task.parent: + partOfTask = task_to_entity(task.parent) + task_entity = TaskEntity( + name=name, + partOfTask=partOfTask, + ) + return PipeKG.add_task(task_entity) + +def data_type_to_entity(data_type: DataFormat) -> DataTypeEntityId: + data_type_entity = DataTypeEntity( + format=data_type, + data_schema=data_type, + ) + return PipeKG.add_data_type(data_type_entity) + +def data_spec_to_entity(data_spec: tuple[str, DataFormat], implementation_name: str = "") -> DataSpecEntityId: + data_spec_entity = DataSpecEntity( + uri=config.PIPEKG_PREFIX + encode_string(implementation_name + "_" + data_spec[0]), + name=data_spec[0], + data_type=data_type_to_entity(data_spec[1]), + ) + return PipeKG.add_data_spec(data_spec_entity) + +def data_to_entity(data: "KgData") -> DataEntityId: + data_entity = DataEntity( + timestamp=None, # TODO + version=None, # TODO + hash=None, # TODO + size=None, # TODO + location=data.path.as_uri(), + data_type=data_type_to_entity(data.format), + ) + return PipeKG.add_data_entity(data_entity) + +def parameter_to_entity(parameter: "Parameter") -> ParameterEntityId: + parameter_entity = ParameterEntity( + key=parameter.name, + alias_keys=parameter.native_keys, + datatype=parameter.datatype, + required=parameter.required, + default_value=parameter.default_value, + allowed_values=parameter.allowed_values, + # minimum=parameter.minimum, + # maximum=parameter.maximum, + # unit=parameter.unit, + ) + return PipeKG.add_parameter(parameter_entity) + + +def config_spec_to_entity(config_spec: "ConfigurationDefinition", implementation_name: str = "") -> ConfigSpecEntityId: + if config_spec is None: + return None + parameter_entities = [parameter_to_entity(parameter) for parameter in config_spec.parameters] + config_spec_entity = ConfigSpecEntity( + name=config_spec.name, + parameters=parameter_entities, + ) + return PipeKG.add_config_spec(config_spec_entity) + +def implementation_to_entity(implementation: "KgTask") -> ImplementationEntityId: + + input_specs = [data_spec_to_entity(data_spec, implementation.name) for data_spec in implementation.input_spec.items()] + + output_specs = [data_spec_to_entity(data_spec, implementation.name) for data_spec in implementation.output_spec.items()] + + realizes_tasks = [task_to_entity(task) for task in implementation.category] + + config_spec = config_spec_to_entity(implementation.config_spec, implementation.name) + + implementation_entity = ImplementationEntity( + ### datatype properties ### + name=implementation.name, + version="1.0.0", # TODO: get version from implementation + ### object properties ### + input_spec=input_specs, + output_spec=output_specs, + realizesTask=realizes_tasks, + usesTool=[], # TODO add usesTool relations + config_spec=config_spec, + ) + return PipeKG.add_implementation(implementation_entity) + +def metric_to_entity(metric: "KgMetric") -> MetricEntityId: + metric_entity = MetricEntity( + name=metric.name, + description=metric.description, + type=metric.aspect.value, + ) + return PipeKG.add_metric(metric_entity) + + +def parameter_binding_to_entity(parameter_binding: "ParameterBinding") -> ParameterBindingEntityId: + parameter_binding_entity = ParameterBindingEntity( + value=parameter_binding.value, + parameter=parameter_to_entity(parameter_binding.parameter), + ) + return PipeKG.add_parameter_binding(parameter_binding_entity) + +def config_binding_to_entity(config_profile: "ConfigurationProfile") -> ConfigBindingEntityId: + config_binding_entity = ConfigBindingEntity( + name=config_profile.name, + binding=[parameter_binding_to_entity(binding) for binding in config_profile.bindings], + ) + return PipeKG.add_config_binding(config_binding_entity) + +def task_run_to_entity(task_run: "KgTaskRun") -> TaskRunEntityId: + + input=[data_to_entity(data) for data in task_run.inputs] + output=[data_to_entity(data) for data in task_run.outputs] + hasConfigBinding=None # TODO + usesImplementation=implementation_to_entity(task_run.task) + hasConfigBinding=config_binding_to_entity(task_run.config_profile) if task_run.config_profile else None + + print(f"hasConfigBinding: {hasConfigBinding}") + + task_run_entity = TaskRunEntity( + status=task_run.status, + started_at=task_run.start_ts, + ended_at=task_run.start_ts + task_run.duration, + input=input, + output=output, + usesImplementation=usesImplementation, + hasConfigBinding=hasConfigBinding, + ) + return PipeKG.add_task_run(task_run_entity) + +def pipeline_run_to_entity(pipeline_run: "KgPipelineRun") -> PipelineRunEntityId: + pipeline_run_entity = PipelineRunEntity( + name=pipeline_run.name, + status=pipeline_run.status, + started_at=pipeline_run.started_at, + ended_at=pipeline_run.ended_at, + ) + return PipeKG.add_pipeline_run(pipeline_run_entity) + +# TODO +# def metric_run_to_entity(metric_run: "MetricResult") -> MetricRunEntityId: +# import time +# import json +# computedMetric = metric_to_entity(metric_run.metric) +# # data_type = data_type_to_entity(DataFormat.ANY) +# input_entities = [KgData(path=metric_run.kg.path, format=DataFormat.ANY)] +# input = [data_to_entity(input_entity) for input_entity in input_entities] +# metric_run_entity = MetricRunEntity( +# status="success", +# started_at=time.time(), +# ended_at=time.time(), +# computedMetric=computedMetric, +# input=input, +# value=metric_run.value, +# details=json.dumps(metric_run.details, default=str) +# ) +# PipeKG.add_metric_run(metric_run_entity) \ No newline at end of file diff --git a/src/kgpipe/common/graph/systemgraph.py b/src/kgpipe/common/graph/systemgraph.py new file mode 100644 index 0000000..7af2736 --- /dev/null +++ b/src/kgpipe/common/graph/systemgraph.py @@ -0,0 +1,352 @@ +import functools +import ast +from uuid import uuid4 +from typing import Any, List, Optional, TYPE_CHECKING +from datetime import datetime, timezone +import hashlib +import json + +from kgcore.api import KnowledgeGraph, KGEntity, KGRelation, KGProperty, new_id +from kgcore.backend.rdf.rdf_rdflib import RDFLibBackend +from kgcore.backend.rdf.rdf_sparql import RDFSparqlBackend, SparqlAuth +from kgcore.model.rdf.rdf_base import RDFBaseModel + +from kgpipe.common.graph.definitions import ( + KGPIPE_NS, + ImplementationEntity, ImplementationEntityId, + TaskEntity, TaskEntityId, + ToolEntity, ToolEntityId, + DataEntity, DataEntityId, + DataSpecEntity, DataSpecEntityId, + DataTypeEntity, DataTypeEntityId, + MetricEntity, MetricEntityId, + MetricRunEntity, MetricRunEntityId, + TaskRunEntity, TaskRunEntityId, + ParameterEntity, ParameterEntityId, + ParameterBindingEntity, ParameterBindingEntityId, + ConfigSpecEntity, ConfigSpecEntityId, + ConfigBindingEntity, ConfigBindingEntityId, +) +from kgpipe.common.config import load_config +from kgpipe.common.util import encode_string + +if TYPE_CHECKING: + from kgpipe.common.models import KgTask, KgTaskReport + +config = load_config() +scheme, rest = config.SYS_KG_URL.split("://") + +backend = RDFLibBackend() +model = RDFBaseModel() + +try: + if scheme == "sparql": + print(f"Using SPARQL backend for system graph: {f"http://{rest}"} with http://github.com/ScaDS/kgpipe/") + backend = RDFSparqlBackend( + endpoint=f"http://{rest}", + update_endpoint=f"http://{rest}", + default_graph="http://github.com/ScaDS/kgpipe/", + auth=SparqlAuth(username=config.SYS_KG_USR, password=config.SYS_KG_PSW)) + else: + raise ValueError(f"Unsupported schema: {scheme}") +except Exception as e: + print(f"Error creating system graph: {e}") + print(f"Using RDFLib memory backend for system graph") + +SYS_KG: KnowledgeGraph = KnowledgeGraph(model=model, backend=backend) + +class PipeKG: + """ + PipeKG is the system graph for the KGpipe framework. + It is a Object Graph Mapper (OGM) for the KGpipe framework. + It is used to store the entities and relations of the KGpipe framework. + """ + + ### Core Layer Entities ### + + @staticmethod + @functools.lru_cache + def add_task(task: TaskEntity) -> TaskEntityId: + entity_id = config.PIPEKG_PREFIX + encode_string(task.name) + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.Task], + properties={ + KGPIPE_NS.name: task.name, + KGPIPE_NS.description: task.description + }, + ) + if task.partOfTask: + SYS_KG.create_relation(type=KGPIPE_NS.partOfTask, source=entity_id, target=task.partOfTask) + return TaskEntityId(entity_id) + + @staticmethod + @functools.lru_cache + def add_tool(tool: ToolEntity): + entity_id = config.PIPEKG_PREFIX + encode_string(tool.name) + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.Tool], + properties={ + KGPIPE_NS.name: tool.name, + KGPIPE_NS.homepage: tool.homepage, + }, + ) + for supports_task in tool.supportsTasks: + SYS_KG.create_relation(type=KGPIPE_NS.supportsTask, source=entity_id, target=supports_task) + return ToolEntityId(entity_id) + + @staticmethod + def add_implementation(implementation: ImplementationEntity): + entity_id = config.PIPEKG_PREFIX + encode_string(implementation.name) + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.Implementation], + properties={ + KGPIPE_NS.name: implementation.name, + KGPIPE_NS.version: implementation.version, + }, + ) + for input_spec in implementation.input_spec: + SYS_KG.create_relation(type=KGPIPE_NS.input, source=entity_id, target=input_spec) + for output_spec in implementation.output_spec: + SYS_KG.create_relation(type=KGPIPE_NS.output, source=entity_id, target=output_spec) + for realizes_task in implementation.realizesTask: + SYS_KG.create_relation(type=KGPIPE_NS.realisesTask, source=entity_id, target=realizes_task) + if implementation.config_spec: + SYS_KG.create_relation(type=KGPIPE_NS.config_spec, source=entity_id, target=implementation.config_spec) + return ImplementationEntityId(entity_id) + + @staticmethod + def find_implementation( + name: Optional[str] = None, + # version: Optional[str] = None, + # input_spec: Optional[List[str]] = None, + # output_spec: Optional[List[str]] = None, + # realizes_task: Optional[List[str]] = None, + # has_parameter: Optional[List[str]] = None, + ) -> List[ImplementationEntity]: + entities: List[KGEntity] = SYS_KG.find_entities( + types=[str(KGPIPE_NS.Implementation)], + ) + implementations = [ImplementationEntity( + uri=entity.id, + name=entity.get_property_value(str(KGPIPE_NS.name))[0], + version=entity.get_property_value(str(KGPIPE_NS.version))[0], + input_spec=[DataSpecEntityId(neighbor.id) for neighbor in SYS_KG.get_neighbors(entity.id, str(KGPIPE_NS.input))], + output_spec=[DataSpecEntityId(neighbor.id) for neighbor in SYS_KG.get_neighbors(entity.id, str(KGPIPE_NS.output))], + realizesTask=[TaskEntityId(neighbor.id) for neighbor in SYS_KG.get_neighbors(entity.id, str(KGPIPE_NS.realisesTask))], + # hasParameter=[ParameterEntityId(neighbor.id) for neighbor in entity.get_neighbors(KGPIPE_NS.hasParameter)], + usesTool=[ToolEntityId(neighbor.id) for neighbor in SYS_KG.get_neighbors(entity.id, str(KGPIPE_NS.usesTool))], + # config_spec=ConfigSpecEntityId(entity.get_property(KGPIPE_NS.config_spec)) if entity.get_property(KGPIPE_NS.config_spec) else None, + ) for entity in entities] + if name is not None: + implementations = [impl for impl in implementations if impl.name == name] + return implementations + + ### Data Layer Entities ### + + @staticmethod + @functools.lru_cache + def add_data_spec(data_spec: DataSpecEntity): + data_spec_entity = SYS_KG.create_entity( + id=data_spec.uri if data_spec.uri else new_id(), + types=[config.ONTOLOGY_PREFIX + "DataSpec"], + properties={ + config.ONTOLOGY_PREFIX + "name": data_spec.name, + }, + ) + SYS_KG.create_relation(type=KGPIPE_NS.data_type, source=data_spec_entity.id, target=data_spec.data_type) + return DataSpecEntityId(data_spec_entity.id) + + @staticmethod + @functools.lru_cache + def add_data_entity(data_entity: DataEntity): + entity_id = config.PIPEKG_PREFIX + new_id() + data_entity_entity = SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.DataEntity], + properties={}, # TODO + # properties={ + # KGPIPE_NS.timestamp: data_entity.timestamp, + # KGPIPE_NS.version: data_entity.version, + # KGPIPE_NS.hash: data_entity.hash, + # KGPIPE_NS.size: data_entity.size, + # }, + ) + SYS_KG.create_relation(type=KGPIPE_NS.location, source=data_entity_entity.id, target=data_entity.location) + SYS_KG.create_relation(type=KGPIPE_NS.data_type, source=data_entity_entity.id, target=data_entity.data_type) + return DataEntityId(data_entity_entity.id) + + @staticmethod + @functools.lru_cache + def add_data_type(data_type: DataTypeEntity) -> DataTypeEntityId: + entity_id = config.PIPEKG_PREFIX + encode_string(data_type.format+"-"+data_type.data_schema) + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.DataType], + properties={ + KGPIPE_NS.format: data_type.format, + KGPIPE_NS.schema: data_type.data_schema, + }, + ) + return DataTypeEntityId(entity_id) + + ### Pipeline Layer Entities ### + + ### Evaluation Layer Entities ### + + def add_metric(metric: MetricEntity): + pass + + ### Run Layer Entities ### + + def add_task_run(task_run: TaskRunEntity): + entity_id = config.PIPEKG_PREFIX + new_id() + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.TaskRun], + properties={ + KGPIPE_NS.status: task_run.status, + KGPIPE_NS.started_at: task_run.started_at, + KGPIPE_NS.ended_at: task_run.ended_at, + }, + ) + for input in task_run.input: + SYS_KG.create_relation(type=KGPIPE_NS.input, source=entity_id, target=input) + for output in task_run.output: + SYS_KG.create_relation(type=KGPIPE_NS.output, source=entity_id, target=output) + SYS_KG.create_relation(type=KGPIPE_NS.usesImplementation, source=entity_id, target=task_run.usesImplementation) + return TaskRunEntityId(entity_id) + + def add_metric_run(metric_run: MetricRunEntity): + pass + + ### Configuration Layer Entities ### + + @staticmethod + @functools.lru_cache + def add_parameter(parameter: ParameterEntity): + + payload = json.dumps(parameter.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) + stable_hash = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] # short suffix + entity_id = config.PIPEKG_PREFIX + encode_string(parameter.key) + "_" + stable_hash + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.Parameter], + properties={ + KGPIPE_NS.key: parameter.key, + KGPIPE_NS.alias_keys: parameter.alias_keys, + KGPIPE_NS.datatype: parameter.datatype, + KGPIPE_NS.required: parameter.required, + KGPIPE_NS.default_value: parameter.default_value, + KGPIPE_NS.allowed_values: parameter.allowed_values, + # KGPIPE_NS.minimum: parameter.minimum, + # KGPIPE_NS.maximum: parameter.maximum, + # KGPIPE_NS.unit: parameter.unit, + }, + ) + return ParameterEntityId(entity_id) + + def find_parameter(name: str): + pass + + @staticmethod + def add_parameter_binding(parameter_binding: ParameterBindingEntity): + payload = json.dumps(parameter_binding.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) + stable_hash = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] # short suffix + entity_id = parameter_binding.parameter + "_" + stable_hash + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.ParameterBinding], + properties={ + KGPIPE_NS.value: parameter_binding.value, + }, + ) + SYS_KG.create_relation(type=KGPIPE_NS.parameter, source=entity_id, target=parameter_binding.parameter) + return ParameterBindingEntityId(entity_id) + + def find_parameter_binding(name: str): + pass + + @staticmethod + @functools.lru_cache + def add_config_spec(config_spec: ConfigSpecEntity): + entity_id = config.PIPEKG_PREFIX + encode_string(config_spec.name) + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.ConfigSpec], + properties={ + KGPIPE_NS.name: config_spec.name, + }, + ) + for parameter in config_spec.parameters: + SYS_KG.create_relation(type=KGPIPE_NS.hasParameter, source=entity_id, target=parameter) + return ConfigSpecEntityId(entity_id) + + + def find_config_spec(name: str): + pass + + @staticmethod + def add_config_binding(config_binding: ConfigBindingEntity): + entity_id = config.PIPEKG_PREFIX + encode_string(config_binding.name) + SYS_KG.create_entity( + id=entity_id, + types=[KGPIPE_NS.ConfigBinding], + properties={ + KGPIPE_NS.name: config_binding.name, + }, + ) + for binding in config_binding.binding: + SYS_KG.create_relation(type=KGPIPE_NS.hasParameterBinding, source=entity_id, target=binding) + return ConfigBindingEntityId(entity_id) + + def find_config_binding(name: str): + pass + + ### Utility Functions ### + + @staticmethod + def sparql_construct(query: str): + backend : RDFSparqlBackend = SYS_KG.backend + result = backend.query_sparql(query) + return result + + @staticmethod + def _prop_value(properties: List[KGProperty], *keys: str) -> Any: + """Find a property value by exact key or key suffix.""" + for prop in properties: + if prop.key in keys: + return prop.value + for prop in properties: + for key in keys: + if prop.key.endswith(key): + return prop.value + return None + + @staticmethod + def _to_list(value: Any) -> List[str]: + """Normalize KG property values to list[str].""" + if value is None: + return [] + if isinstance(value, list): + return [str(v) for v in value] + if isinstance(value, tuple): + return [str(v) for v in value] + if isinstance(value, str): + text = value.strip() + if not text: + return [] + # Stored literals may contain Python-list string repr. + if text.startswith("[") and text.endswith("]"): + try: + parsed = ast.literal_eval(text) + except (ValueError, SyntaxError): + return [text] + if isinstance(parsed, list): + return [str(v) for v in parsed] + return [text] + return [str(value)] + + diff --git a/src/kgpipe/common/model/__init__.py b/src/kgpipe/common/model/__init__.py index 4a7f388..8335da4 100644 --- a/src/kgpipe/common/model/__init__.py +++ b/src/kgpipe/common/model/__init__.py @@ -1,2 +1,10 @@ from .pipeline import KgPipe, KgPipePlan, KgPipePlanStep -from .task import TaskInput, TaskOutput \ No newline at end of file +from .task import TaskInput, TaskOutput, KgTask, KgTaskRun +from .evaluation import Metric, EvaluationReport +from .kg import KG +from .data import Data, DataFormat, DataSet, KgData +from .default_catalog import BasicDataFormats, CustomDataFormats, BasicTaskCategoryCatalog + +__all__ = [ + "KgPipe", "KgPipePlan", "KgPipePlanStep", "KgStageReport", "KgTask", "KgTaskRun", "Metric", "EvaluationReport", "KG", "TaskInput", "TaskOutput", "KgTaskRun", "Data", "DataSet", "BasicDataFormats", "CustomDataFormats", "BasicTaskCategoryCatalog", "KgData" +] \ No newline at end of file diff --git a/src/kgpipe/common/model/configuration.py b/src/kgpipe/common/model/configuration.py index 6dcaace..4ebacb9 100644 --- a/src/kgpipe/common/model/configuration.py +++ b/src/kgpipe/common/model/configuration.py @@ -19,7 +19,6 @@ class ParameterType(Enum): object = "object" -@kg_class() class Parameter(BaseModel): """ Configuration parameter definition, not the actual value of the parameter in the pipeline execution @@ -46,7 +45,6 @@ class Parameter(BaseModel): unit: Optional[str] = None -@kg_class() class ParameterBinding(BaseModel): """ Binding of a configuration parameter to a value in the pipeline execution @@ -54,25 +52,50 @@ class ParameterBinding(BaseModel): parameter: Parameter value: str | int | float | bool # TODO extend to more types? -@kg_class() + class ConfigurationDefinition(BaseModel): """ - Possible configurations of a task + Possible configurations specification of a task """ name: str description: Optional[str] = None parameters: List[Parameter] = field(default_factory=list) - -@kg_class() + + class ConfigurationProfile(BaseModel): """ - Configuration profile definition, not the actual values of the parameters in the pipeline execution + Configuration profile specification, the actual values of the parameters in the pipeline execution """ name: str definition: ConfigurationDefinition description: Optional[str] = None bindings: List[ParameterBinding] = field(default_factory=list) + def get_parameter(self, name: str) -> Parameter: + for parameter in self.definition.parameters: + if parameter.name == name: + return parameter + raise ValueError(f"Parameter {name} not found in configuration profile {self.name}") + + def get_parameter_binding(self, name: str) -> ParameterBinding: + for binding in self.bindings: + if binding.parameter.name == name: + return binding + raise ValueError(f"Parameter binding {name} not found in configuration profile {self.name}") + + def get_parameter_value(self, name: str) -> str | int | float | bool: + return self.get_parameter_binding(name).value + +class ConfigurationBuilder(): + def __init__(self, config_spec: ConfigurationDefinition): + self.config_spec = config_spec + self.config_profile = ConfigurationProfile(name=config_spec.name, definition=config_spec) + + def add_parameter(self, name: str, value: str | int | float | bool) -> None: + self.config_profile.bindings.append(ParameterBinding(parameter=self.get_parameter(name), value=value)) + + + class ConfigurationMapping(BaseModel): """ Mapping of a configuration profile to a task implementation diff --git a/src/kgpipe/common/model/data.py b/src/kgpipe/common/model/data.py index 0b66c0b..0e6eb49 100644 --- a/src/kgpipe/common/model/data.py +++ b/src/kgpipe/common/model/data.py @@ -1,228 +1,23 @@ from __future__ import annotations -import os -import time import uuid -from abc import ABC, abstractmethod from dataclasses import dataclass, field -from datetime import datetime from enum import Enum from pathlib import Path -from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple, Union, Type -import json -from uuid import uuid4 -import logging -import shutil -from rdflib import Graph +from typing import Any, Dict, Optional, Union from pydantic import BaseModel, field_validator -from pydantic_core import core_schema +from .default_catalog import BasicDataFormats, CustomDataFormats -# Format descriptions for built-in formats -FORMAT_DESCRIPTIONS = { - "ttl": "Turtle RDF format", - "nquads": "N-Quads RDF format", - "json": "JSON format", - "csv": "CSV format", - "parquet": "Parquet format", - "xml": "XML format", - "rdf": "RDF format", - "jsonld": "JSON-LD format", - "txt": "Text format", - "paris_csv": "Paris CSV format", - "openrefine_json": "OpenRefine JSON format", - "limes_xml": "LIMES XML format", - "spotlight_json": "DBpedia Spotlight JSON format", - "falcon_json": "FALCON JSON format", - "ie_json": "Information Extraction JSON format", - "valentine_json": "Valentine JSON format", - "corenlp_json": "CoreNLP JSON format", - "openie_json": "OpenIE JSON format", - "agreementmaker_rdf": "AgreementMaker RDF format", - "em_json": "Entity Matching JSON format", -} +# Backward-compatible alias used across the codebase. +DataFormat = BasicDataFormats -class DataFormat(Enum): - """Built-in data formats with enum benefits.""" - # Standard formats - RDF_TTL = "ttl" - RDF_NQUADS = "nq" - RDF_NTRIPLES = "nt" - JSON = "json" - CSV = "csv" - PARQUET = "parquet" - RDF_XML = "xml" - RDF = "rdf" - RDF_JSONLD = "jsonld" - TEXT = "txt" - XML = "xml" - ANY = "any" - - # Tool-specific formats - PARIS_CSV = "paris.csv" - OPENREFINE_JSON = "openrefine.json" - LIMES_XML = "limes.xml" - SPOTLIGHT_JSON = "spotlight.json" - FALCON_JSON = "falcon.json" - VALENTINE_JSON = "valentine.json" - CORENLP_JSON = "corenlp.json" - OPENIE_JSON = "openie.json" - AGREEMENTMAKER_RDF = "agreementmaker.rdf" - - # Exchange formats - ER_JSON = "er.json" # Entity Resolution JSON format - TE_JSON = "te.json" # Text Extraction JSON format - - # LLM Tasks - JSON_ONTO_MAPPING_JSON = "json_onto_mapping.json" - - @classmethod - def from_extension(cls, extension: str) -> DataFormat: - """Get a format by file extension. If fails print available formats and raise ValueError.""" - try: - return cls(extension) - except ValueError: - print(f"Available formats: {[f.value for f in cls]}") - raise ValueError(f"Invalid format: {extension}") - - - @property - def extension(self) -> str: - """Get the file extension for this format.""" - return self.value - - @property - def description(self) -> str: - """Get the description for this format.""" - return FORMAT_DESCRIPTIONS.get(self.value, self.value) - - @property - def is_tool_specific(self) -> bool: - """Check if this is a tool-specific format.""" - tool_specific_formats = { - "paris_csv", "openrefine_json", "limes_xml", "spotlight_json", - "falcon_json", "ie_json", "valentine_json", "corenlp_json", - "openie_json", "agreementmaker_rdf", "em_json" - } - return self.value in tool_specific_formats - - def __str__(self) -> str: - return f".{self.value}" - - def __repr__(self) -> str: - return f".{self.value}" - - -class DynamicFormat: - """Dynamic format for submodules to register custom formats.""" - - def __init__(self, name: str, extension: str, description: str, is_tool_specific: bool = False): - self.name = name - self.extension = extension - self.description = description - self.is_tool_specific = is_tool_specific - - @classmethod - def __get_pydantic_core_schema__(cls, source_type: Any, handler) -> Any: - """Provide Pydantic schema for this type.""" - return core_schema.union_schema([ - core_schema.is_instance_schema(cls), - core_schema.str_schema() - ]) - - @property - def value(self) -> str: - """Get the format value (same as name for compatibility).""" - return self.name - - def __eq__(self, other) -> bool: - """Compare formats by name.""" - if isinstance(other, DynamicFormat): - return self.name == other.name - elif isinstance(other, DataFormat): - return self.name == other.value - elif isinstance(other, str): - return self.name == other - return False - - def __hash__(self) -> int: - """Hash based on name.""" - return hash(self.name) - - def __str__(self) -> str: - return f"DynamicFormat({self.name})" - - def __repr__(self) -> str: - return f"DynamicFormat(name='{self.name}', extension='{self.extension}', description='{self.description}', is_tool_specific={self.is_tool_specific})" - - -class FormatRegistry: - """Registry for managing and discovering data formats.""" - - _dynamic_formats: Dict[str, DynamicFormat] = {} - - @classmethod - def register_format(cls, name: str, extension: str, description: str, is_tool_specific: bool = False) -> DynamicFormat: - """Register a new dynamic data format.""" - if name in cls._dynamic_formats: - return cls._dynamic_formats[name] - - format_obj = DynamicFormat(name, extension, description, is_tool_specific) - cls._dynamic_formats[name] = format_obj - return format_obj - - @classmethod - def get_format(cls, name: str) -> Optional[Union[DataFormat, DynamicFormat]]: - """Get a format by name, checking built-in formats first.""" - # Try built-in formats first - try: - return DataFormat(name) - except ValueError: - # Then check dynamic formats - return cls._dynamic_formats.get(name) - - @classmethod - def list_formats(cls, tool_specific_only: bool = False) -> List[Union[DataFormat, DynamicFormat]]: - """List all registered formats.""" - formats = list(DataFormat) + list(cls._dynamic_formats.values()) - if tool_specific_only: - formats = [f for f in formats if getattr(f, 'is_tool_specific', False)] - return formats - - @classmethod - def list_standard_formats(cls) -> List[Union[DataFormat, DynamicFormat]]: - """List all standard (non-tool-specific) formats.""" - formats = list(DataFormat) + list(cls._dynamic_formats.values()) - return [f for f in formats if not getattr(f, 'is_tool_specific', False)] - - @classmethod - def list_tool_specific_formats(cls) -> List[Union[DataFormat, DynamicFormat]]: - """List all tool-specific formats.""" - formats = list(DataFormat) + list(cls._dynamic_formats.values()) - return [f for f in formats if getattr(f, 'is_tool_specific', False)] - - @classmethod - def list_rdf_formats(cls) -> List[Union[DataFormat, DynamicFormat]]: - """List all RDF formats.""" - rdf_formats = [DataFormat.RDF_TTL, DataFormat.RDF_NQUADS, DataFormat.RDF, DataFormat.RDF_JSONLD] - dynamic_rdf = [f for f in cls._dynamic_formats.values() if 'rdf' in f.name.lower() or 'ttl' in f.name.lower()] - return rdf_formats + dynamic_rdf - - @classmethod - def list_text_formats(cls) -> List[Union[DataFormat, DynamicFormat]]: - """List all text formats.""" - text_formats = [DataFormat.JSON, DataFormat.CSV, DataFormat.XML, DataFormat.TEXT] - dynamic_text = [f for f in cls._dynamic_formats.values() if f.name.lower() in ['json', 'csv', 'xml', 'txt', 'yaml']] - return text_formats + dynamic_text - - @classmethod - def clear_dynamic_formats(cls) -> None: - """Clear all dynamically registered formats (useful for testing).""" - cls._dynamic_formats.clear() +# Type alias for any format +Format = Union[DataFormat, CustomDataFormats] -# Type alias for any format -Format = Union[DataFormat, DynamicFormat] +def _format_value(fmt: Format) -> str: + return str(fmt.value) class Data(BaseModel): """Represents a data file with a specific format.""" @@ -246,16 +41,16 @@ def __init__(self, *args, **data): @classmethod def validate_format(cls, v): """Convert string format to proper Format object.""" + if isinstance(v, (DataFormat, CustomDataFormats)): + return v + if isinstance(v, Enum) and isinstance(v.value, str): + # Allow user-defined enum values for strong typing/autocomplete. + return v if isinstance(v, str): # Try to convert string to DataFormat enum try: return DataFormat(v) except ValueError: - # If it's not a DataFormat, it might be a DynamicFormat - from .models import FormatRegistry - dynamic_format = FormatRegistry.get_format(v) - if dynamic_format: - return dynamic_format raise ValueError(f"Unknown format: {v}") return v @@ -266,23 +61,19 @@ def exists(self) -> bool: def to_dict(self) -> Dict[str, str]: return { "path": str(self.path), - "format": self.format.value + "format": _format_value(self.format) } def __str__(self) -> str: - return f"Data({self.path}, {self.format.value if isinstance(self.format, DynamicFormat) else self.format})" + return f"Data({self.path}, {_format_value(self.format)})" def __eq__(self, other): """Custom equality to handle format comparison.""" if not isinstance(other, Data): return False - return (self.path == other.path and - (hasattr(self.format, 'value') and hasattr(other.format, 'value') and - self.format.value == other.format.value)) - - - + return self.path == other.path and _format_value(self.format) == _format_value(other.format) +KgData = Data @dataclass class DataSet: @@ -307,4 +98,4 @@ def exists(self) -> bool: return self.path.exists() def __str__(self) -> str: - return f"DataSet({self.name}, {self.path}, {self.format.value})" + return f"DataSet({self.name}, {self.path}, {_format_value(self.format)})" diff --git a/src/kgpipe/common/model/default_catalog.py b/src/kgpipe/common/model/default_catalog.py index 0fde757..24fd368 100644 --- a/src/kgpipe/common/model/default_catalog.py +++ b/src/kgpipe/common/model/default_catalog.py @@ -1,13 +1,203 @@ +from __future__ import annotations +from dataclasses import dataclass +from enum import Enum +from typing import Dict, List, Optional -# TODO impl later for typed api -class TaskCategory():pass -class EntityResolution(TaskCategory): pass -class EntityMatching(EntityResolution): pass -class Fusion(EntityResolution): pass -class InformationExtraction(TaskCategory): pass -class EntityLinking(InformationExtraction): pass -class RelationExtraction(InformationExtraction): pass -class RelationLinking(InformationExtraction): pass -class DataMapping(TaskCategory): pass \ No newline at end of file +@dataclass(frozen=True) +class TaskCategory: + name: str + parent: Optional[TaskCategory] = None + description: str = "" + + + + + +class BasicTaskCategoryCatalog: + """ + Hierarchical catalog for task categories. + Supports default categories and custom category registration. + """ + entity_resolution = TaskCategory(name="EntityResolution") + entity_matching = TaskCategory(name="EntityMatching", parent=entity_resolution) + fusion = TaskCategory(name="Fusion", parent=entity_resolution) + information_extraction = TaskCategory(name="InformationExtraction") + entity_linking = TaskCategory(name="EntityLinking", parent=information_extraction) + relation_extraction = TaskCategory(name="RelationExtraction", parent=information_extraction) + relation_linking = TaskCategory(name="RelationLinking", parent=information_extraction) + data_mapping = TaskCategory(name="DataMapping") + blocking = TaskCategory(name="Blocking", parent=entity_resolution) + clustering = TaskCategory(name="Clustering", parent=entity_resolution) + + + # @dataclass(frozen=True) + # class TaskCategoryNode: + # name: str + # parent: Optional[str] = None + # description: str = "" + + # _nodes: Dict[str, TaskCategoryNode] = { + # "TaskCategory": TaskCategoryNode(name="TaskCategory", parent=None, description="Root category"), + # "EntityResolution": TaskCategoryNode(name="EntityResolution", parent="TaskCategory"), + # "Blocking": TaskCategoryNode(name="Blocking", parent="EntityResolution"), + # "EntityMatching": TaskCategoryNode(name="EntityMatching", parent="EntityResolution"), + # "Matching": TaskCategoryNode(name="Matching", parent="EntityResolution"), + # "Clustering": TaskCategoryNode(name="Clustering", parent="EntityResolution"), + # "Fusion": TaskCategoryNode(name="Fusion", parent="EntityResolution"), + # "InformationExtraction": TaskCategoryNode(name="InformationExtraction", parent="TaskCategory"), + # "EntityLinking": TaskCategoryNode(name="EntityLinking", parent="InformationExtraction"), + # "RelationExtraction": TaskCategoryNode(name="RelationExtraction", parent="InformationExtraction"), + # "RelationLinking": TaskCategoryNode(name="RelationLinking", parent="InformationExtraction"), + # "DataMapping": TaskCategoryNode(name="DataMapping", parent="TaskCategory"), + # } + + # @classmethod + # def has(cls, category: str) -> bool: + # return category in cls._nodes + + # @classmethod + # def register(cls, name: str, parent: str = "TaskCategory", description: str = "") -> None: + # if parent is not None and parent not in cls._nodes: + # raise ValueError(f"Unknown parent category: {parent}") + # cls._nodes[name] = TaskCategoryNode(name=name, parent=parent, description=description) + + # @classmethod + # def get_parent(cls, category: str) -> Optional[str]: + # node = cls._nodes.get(category) + # if node is None: + # raise ValueError(f"Unknown category: {category}") + # return node.parent + + # @classmethod + # def get_children(cls, category: str) -> List[str]: + # if category not in cls._nodes: + # raise ValueError(f"Unknown category: {category}") + # return sorted([node.name for node in cls._nodes.values() if node.parent == category]) + + # @classmethod + # def get_ancestors(cls, category: str) -> List[str]: + # if category not in cls._nodes: + # raise ValueError(f"Unknown category: {category}") + # ancestors: List[str] = [] + # cursor = cls._nodes[category].parent + # while cursor is not None: + # ancestors.append(cursor) + # cursor = cls._nodes[cursor].parent + # return ancestors + + # @classmethod + # def get_descendants(cls, category: str) -> List[str]: + # if category not in cls._nodes: + # raise ValueError(f"Unknown category: {category}") + # descendants: List[str] = [] + # queue = cls.get_children(category) + # while queue: + # current = queue.pop(0) + # descendants.append(current) + # queue.extend(cls.get_children(current)) + # return descendants + + # @classmethod + # def is_subtask_of(cls, category: str, parent: str) -> bool: + # if category not in cls._nodes or parent not in cls._nodes: + # return False + # return parent in cls.get_ancestors(category) + + # @classmethod + # def list_categories(cls) -> List[str]: + # return sorted(cls._nodes.keys()) + + +class BasicDataFormats(str, Enum): + """Framework-provided data formats with IDE autocomplete.""" + + # Standard formats + RDF_TTL = "ttl" + RDF_NQUADS = "nq" + RDF_NTRIPLES = "nt" + JSON = "json" + CSV = "csv" + PARQUET = "parquet" + RDF_XML = "xml" + RDF = "rdf" + RDF_JSONLD = "jsonld" + TEXT = "txt" + XML = "xml" + ANY = "any" + + # Tool-specific formats + PARIS_CSV = "paris.csv" + OPENREFINE_JSON = "openrefine.json" + LIMES_XML = "limes.xml" + SPOTLIGHT_JSON = "spotlight.json" + FALCON_JSON = "falcon.json" + VALENTINE_JSON = "valentine.json" + CORENLP_JSON = "corenlp.json" + OPENIE_JSON = "openie.json" + AGREEMENTMAKER_RDF = "agreementmaker.rdf" + + # Exchange formats + ER_JSON = "er.json" + TE_JSON = "te.json" + + # LLM task outputs + JSON_ONTO_MAPPING_JSON = "json_onto_mapping.json" + + @property + def extension(self) -> str: + return self.value + + @property + def description(self) -> str: + return BASIC_FORMAT_DESCRIPTIONS.get(self.value, self.value) + + @property + def is_tool_specific(self) -> bool: + return "." in self.value and self.value not in {"jsonld"} + + @classmethod + def from_extension(cls, extension: str) -> "BasicDataFormats": + try: + return cls(extension) + except ValueError as exc: + available = [f.value for f in cls] + raise ValueError(f"Invalid format: {extension}. Available formats: {available}") from exc + + +class CustomDataFormats(str, Enum): + """ + Base enum for user-defined formats. + Define project-specific formats by subclassing this enum. + """ + + @property + def extension(self) -> str: + return self.value + + +BASIC_FORMAT_DESCRIPTIONS: dict[str, str] = { + "ttl": "Turtle RDF format", + "nq": "N-Quads RDF format", + "json": "JSON format", + "csv": "CSV format", + "parquet": "Parquet format", + "xml": "XML format", + "rdf": "RDF format", + "jsonld": "JSON-LD format", + "txt": "Text format", + "paris.csv": "Paris CSV format", + "openrefine.json": "OpenRefine JSON format", + "limes.xml": "LIMES XML format", + "spotlight.json": "DBpedia Spotlight JSON format", + "falcon.json": "FALCON JSON format", + "valentine.json": "Valentine JSON format", + "corenlp.json": "CoreNLP JSON format", + "openie.json": "OpenIE JSON format", + "agreementmaker.rdf": "AgreementMaker RDF format", + "er.json": "Entity Resolution JSON format", + "te.json": "Text Extraction JSON format", + "json_onto_mapping.json": "JSON ontology mapping format", + "any": "Any format", +} \ No newline at end of file diff --git a/src/kgpipe/common/model/evaluation.py b/src/kgpipe/common/model/evaluation.py index baefa87..be5c6d7 100644 --- a/src/kgpipe/common/model/evaluation.py +++ b/src/kgpipe/common/model/evaluation.py @@ -1,28 +1,19 @@ from __future__ import annotations -import os -import time -import uuid from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime -from enum import Enum -from pathlib import Path -from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple, Union, Type -import json +from typing import Any, Dict from uuid import uuid4 -import logging -import shutil -from rdflib import Graph -from pydantic import BaseModel, field_validator -from pydantic_core import core_schema from kgpipe.common.model.kg import KG +# TODO move parts from kgpipe.evaluation.base to here + class Metric(ABC): """Abstract base class for evaluation metrics.""" - def __init__(self, name: str, description: Optional[str] = None): + def __init__(self, name: str, description: str | None = None): self.name = name self.description = description or name @@ -47,7 +38,7 @@ class EvaluationReport: def __post_init__(self): if not self.id: - self.id = str(uuid.uuid4()) + self.id = str(uuid4().hex) def add_metric(self, name: str, value: float) -> None: """Add a metric result to the report.""" diff --git a/src/kgpipe/common/model/kg.py b/src/kgpipe/common/model/kg.py index 92d5ab6..9e08c58 100644 --- a/src/kgpipe/common/model/kg.py +++ b/src/kgpipe/common/model/kg.py @@ -1,27 +1,14 @@ from __future__ import annotations -import os -import time import uuid -from abc import ABC, abstractmethod from dataclasses import dataclass, field -from datetime import datetime -from enum import Enum from pathlib import Path -from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple, Union, Type -import json -from uuid import uuid4 -import logging -import shutil -from rdflib import Graph -from pydantic import BaseModel, field_validator -from pydantic_core import core_schema +from typing import Any, Dict, List, Optional +from rdflib import Graph, SKOS, RDF from .data import Format from .pipeline import KgPipePlan -from rdflib import SKOS - # TODO check if this is still needed or if we can use the KG from kgcore and only use Data and DataSet @dataclass @@ -76,4 +63,17 @@ def exists(self) -> bool: return self.path.exists() def __str__(self) -> str: - return f"KG({self.name}, {self.path}, {self.format.value})" \ No newline at end of file + return f"KG({self.name}, {self.path}, {self.format.value})" + + +# TODO wip class for central KgPipe KG entity + +class KgKg: + """Represents a KG for the KgPipe framework.""" + # data: List[KgData] + # provenance: str + + @staticmethod + def load_from_plan(plan: KgPipePlan) -> KG: + pass + pass \ No newline at end of file diff --git a/src/kgpipe/common/model/pipeline.py b/src/kgpipe/common/model/pipeline.py index 5ee69f8..00495ef 100644 --- a/src/kgpipe/common/model/pipeline.py +++ b/src/kgpipe/common/model/pipeline.py @@ -20,7 +20,7 @@ from .task import KgTask, KgTaskReport # from .kg import KG from kgpipe.common.annotations import kg_class -from kgpipe.common.systemgraph import PipeKG +from kgpipe.common.graph.systemgraph import PipeKG class KgPipePlanStep(BaseModel): @@ -29,7 +29,7 @@ class KgPipePlanStep(BaseModel): input: List[Data] output: List[Data] -kg_class() +# kg_class() class KgPipePlan(BaseModel): """A KG pipeline plan.""" steps: List[KgPipePlanStep] @@ -41,7 +41,7 @@ class KgPipePlan(BaseModel): # return f"KgTaskReport({self.task_name}, {self.status}, {self.duration:.2f}s)" # TODO rename to KgPipeReport -@kg_class() +# @kg_class() class KgStageReport(BaseModel): """Report of a stage execution.""" stage_name: str @@ -51,6 +51,8 @@ class KgStageReport(BaseModel): status: str error: Optional[str] = None +KgPipelineRun = KgStageReport + # @dataclass # class Stage: # """Represents a stage in a pipeline, containing one or more tasks.""" @@ -78,7 +80,7 @@ class KgStageReport(BaseModel): # TODO rename to Pipeline -@kg_class() +# @kg_class() @dataclass class KgPipe: """A KG pipeline using a list of tasks.""" @@ -227,44 +229,8 @@ def run(self, stable_files_override: bool = False) -> List[KgTaskReport]: reports.append(report) - from kgpipe.common.definitions import PipelineRunEntity, TaskRunEntity, ImplementationEntity, TaskEntity, ImplementationEntityId, TaskEntityId - from kgcore.api.kg import KGId - from kgpipe.common.config import config - from kgpipe.common.definitions import DataHandle - - # TODO this is a workaround for now, taskrun should be built from the task itself - def build_pipeline_run_entity(reports: List[KgTaskReport]) -> PipelineRunEntity: - - task_runs: List[TaskRunEntity] = [] - for idx, report in enumerate(reports): - - - # def get_implementation_entity(report: KgTaskReport) -> ImplementationEntityId: - # return PipeKG.find_implementation_by_name(report.task_name).id - - task_runs.append(TaskRunEntity( - number=idx, - name=report.task_name, - status=report.status, - started_at=report.start_ts, - ended_at=report.start_ts + report.duration, - executesTask=TaskEntityId(config.PIPEKG_PREFIX+report.task_name), - usesImplementation=ImplementationEntityId(config.PIPEKG_PREFIX+report.task_name+"Impl"), - input=[DataHandle(uri=str(input_data.path), type=input_data.format) for input_data in report.inputs], - output=[DataHandle(uri=str(output_data.path), type=output_data.format) for output_data in report.outputs], - hasParameterBinding=[] - )) - - return PipelineRunEntity( - name=self.name, - status="success", - started_at=time.time(), - ended_at=time.time(), - hasTaskRun=task_runs - ) - - pipeline_run_entity = build_pipeline_run_entity(reports) - PipeKG.add_pipeline_run(pipeline_run_entity) + # pipeline_run_entity = reports_to_pipeline_run_entity(reports, self.name) + # PipeKG.add_pipeline_run(pipeline_run_entity) return reports diff --git a/src/kgpipe/common/model/task.py b/src/kgpipe/common/model/task.py index 7d11cad..e901aa9 100644 --- a/src/kgpipe/common/model/task.py +++ b/src/kgpipe/common/model/task.py @@ -7,25 +7,35 @@ from pydantic import BaseModel import time import shutil +from uuid import uuid4 +import inspect from kgpipe.common.model.default_catalog import TaskCategory -from .configuration import Parameter, ConfigurationDefinition -from kgpipe.common.annotations import kg_class +from .configuration import ( + Parameter, + ConfigurationDefinition, + ConfigurationProfile, + ParameterType, +) +from kgpipe.common.graph.systemgraph import PipeKG +from kgpipe.common.graph.mapper import task_run_to_entity type TaskName = str type TaskInput = Dict[TaskName, Data] type TaskOutput = Dict[TaskName, Data] -@kg_class() class KgTaskReport(BaseModel): """Report of a task execution.""" - task_name: str + task: "KgTask" inputs: List[Data] outputs: List[Data] start_ts: float duration: float status: str error: Optional[str] = None + config_profile: Optional[ConfigurationProfile] = None + +KgTaskRun = KgTaskReport class TaskStatus(Enum): """Status of a task in a pipeline.""" @@ -35,13 +45,10 @@ class TaskStatus(Enum): FAILED = "failed" SKIPPED = "skipped" - - # # TODO impl later for typed api # class TaskCatalog(): # pass -@kg_class() @dataclass class KgTask: """Represents a task that can be executed in a pipeline.""" @@ -52,6 +59,8 @@ class KgTask: description: Optional[str] = None category: List[TaskCategory] = field(default_factory=list) config_spec: Optional[ConfigurationDefinition] = None + tools: List[str] = field(default_factory=list) + trace_task_run: bool = False def __post_init__(self): if not self.name: @@ -63,70 +72,32 @@ def __post_init__(self): if not callable(self.function): raise ValueError("Function must be callable") - def run(self, inputs: List[Data], outputs: List[Data], stable_files_override: bool = False, configProfile: Optional[str] = None) -> KgTaskReport: + + # TODO if configProfile is not provided, use the default config profile derived from the config_spec + def run(self, inputs: List[Data], outputs: List[Data], stable_files_override: bool = False, configProfile: Optional[ConfigurationProfile] = None) -> KgTaskReport: """Execute the task with given inputs and outputs.""" start = time.time() + report: KgTaskReport try: named_inputs = self._match(inputs, self.input_spec) named_outputs = self._match(outputs, self.output_spec) - - # print(f"Running {self.name} with\n\t inputs: {[str(i.path) for i in named_inputs.values()]}\n\t outputs: {[str(o.path) for o in named_outputs.values()]}") print(f"Running {self.name} with\n\t inputs: {named_inputs}\n\t outputs: {named_outputs}") - - # Validate that all required inputs and outputs are present - if len(named_inputs) != len(self.input_spec): - missing = set(self.input_spec.keys()) - set(named_inputs.keys()) - available = {obj.format.value: obj for obj in inputs} - expected = {k: v.value for k, v in self.input_spec.items()} - raise ValueError( - f"Missing required inputs: {missing}. " - f"Expected: {expected}. " - f"Available: {[f'{obj.path} ({obj.format.value})' for obj in inputs]}" - ) - - if len(named_outputs) != len(self.output_spec): - missing = set(self.output_spec.keys()) - set(named_outputs.keys()) - available = {obj.format.value: obj for obj in outputs} - expected = {k: v.value for k, v in self.output_spec.items()} - raise ValueError( - f"Missing required outputs: {missing}. " - f"Expected: {expected}. " - f"Available: {[f'{obj.path} ({obj.format.value})' for obj in outputs]}" - ) - if stable_files_override: - for output in named_outputs.values(): - # delete the file or directory - if output.path.exists(): - if output.path.is_file(): - output.path.unlink() - elif output.path.is_dir(): - shutil.rmtree(output.path) - - # if all outputs exists skip the task - if all(output.path.exists() for output in named_outputs.values()): + self._validate_required_data(named_inputs, self.input_spec, "inputs", inputs) + self._validate_required_data(named_outputs, self.output_spec, "outputs", outputs) + self._prepare_outputs(named_outputs, stable_files_override) + + # TODO needs to check config profile changes, or maybe not + if self._should_skip(named_outputs): print(f"Skipping task {self.name} because all outputs exist") - # exit(1) - # TODO do not override old KgTaskReport - return KgTaskReport( - task_name=self.name, - inputs=list(named_inputs.values()), - outputs=list(named_outputs.values()), - start_ts=start, - duration=time.time() - start, - status="skipped", - ) + report = self._build_report(start, "skipped", list(named_inputs.values()), list(named_outputs.values()), config_profile=configProfile) + self._trace_task_run_to_pipekg(report) + return report - self.function(named_inputs, named_outputs) - - return KgTaskReport( - task_name=self.name, - inputs=list(named_inputs.values()), - outputs=list(named_outputs.values()), - start_ts=start, - duration=time.time() - start, - status="success", - ) + self._call_function(named_inputs, named_outputs, configProfile) + report = self._build_report(start, "success", list(named_inputs.values()), list(named_outputs.values()), config_profile=configProfile) + self._trace_task_run_to_pipekg(report) + return report except Exception as e: print(f"An error occurred while running the task '{self.name}'.") @@ -134,16 +105,185 @@ def run(self, inputs: List[Data], outputs: List[Data], stable_files_override: bo print(f"Exception message: {e}") import traceback traceback.print_exc() - return KgTaskReport( - task_name=self.name, - inputs=inputs, - outputs=outputs, - start_ts=start, - duration=time.time() - start, - status="failed", - error=str(e) + report = self._build_report(start, "failed", inputs, outputs, error=str(e), config_profile=configProfile) + self._trace_task_run_to_pipekg(report) + return report + + def _trace_task_run_to_pipekg(self, report: KgTaskReport) -> None: + # TODO print(f"Tracing task run to pipekg: {report}") + if not self.trace_task_run: + return + task_run_to_entity(report) + + def _call_function( + self, + named_inputs: Dict[str, Data], + named_outputs: Dict[str, Data], + config_profile: Optional[object], + ) -> None: + """ + Call the wrapped task function with or without config. + + Supported task signatures: + - fn(inputs, outputs) + - fn(inputs, outputs, config) + - fn(inputs, outputs, *, config=...) + - fn(inputs, outputs, **kwargs) (will receive config=... if provided) + """ + sig = inspect.signature(self.function) + params = sig.parameters + + accepts_var_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()) + has_config_param = "config" in params + + if config_profile is None: + # If config is required positionally/without default, fail early with a clear error. + if has_config_param: + p = params["config"] + if p.default is inspect._empty and p.kind not in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ): + raise TypeError( + f"{self.name} requires a 'config' argument but none was provided. " + f"Pass configProfile=... to KgTask.run(), or make 'config' optional." + ) + self.function(named_inputs, named_outputs) + return + + # config is provided: pass it only if the function can accept it + if has_config_param or accepts_var_kwargs: + # If the task declares a config spec, we require a structured ConfigurationProfile. + if self.config_spec is not None and not isinstance(config_profile, ConfigurationProfile): + raise TypeError( + f"{self.name} expects configProfile to be a ConfigurationProfile " + f"because it declares config_spec='{self.config_spec.name}', " + f"got {type(config_profile).__name__}." + ) + if isinstance(config_profile, ConfigurationProfile) and self.config_spec is not None: + self._validate_config(config_profile, self.config_spec) + self.function(named_inputs, named_outputs, config=config_profile) + return + + # Function cannot accept config: ignore it + self.function(named_inputs, named_outputs) + + def _validate_config(self, config_profile: ConfigurationProfile, config_spec: ConfigurationDefinition) -> None: + if config_profile.definition.name != config_spec.name: + raise ValueError( + f"Config profile definition '{config_profile.definition.name}' does not match " + f"task config spec '{config_spec.name}'." ) + spec_by_name: Dict[str, Parameter] = {p.name: p for p in config_spec.parameters} + spec_by_key: Dict[str, Parameter] = {} + for p in config_spec.parameters: + spec_by_key[p.name] = p + for nk in p.native_keys: + spec_by_key[nk] = p + + bound: Dict[str, object] = {} + for binding in config_profile.bindings: + raw_key = binding.parameter.name + if raw_key not in spec_by_key: + raise ValueError( + f"Unknown config parameter '{raw_key}' for spec '{config_spec.name}'. " + f"Known: {sorted(spec_by_name.keys())}" + ) + param = spec_by_key[raw_key] + value = binding.value + bound[param.name] = value + + if param.datatype == ParameterType.boolean and not isinstance(value, bool): + raise TypeError(f"Config parameter '{param.name}' expects boolean, got {type(value).__name__}") + if param.datatype == ParameterType.integer and not isinstance(value, int): + raise TypeError(f"Config parameter '{param.name}' expects integer, got {type(value).__name__}") + if param.datatype == ParameterType.number and not isinstance(value, (int, float)): + raise TypeError(f"Config parameter '{param.name}' expects number, got {type(value).__name__}") + if param.datatype == ParameterType.string and not isinstance(value, str): + raise TypeError(f"Config parameter '{param.name}' expects string, got {type(value).__name__}") + + if param.allowed_values and value not in param.allowed_values: + raise ValueError( + f"Config parameter '{param.name}' value {value!r} not in allowed_values {param.allowed_values!r}" + ) + + if param.minimum is not None: + if not isinstance(value, (int, float)): + raise TypeError(f"Config parameter '{param.name}' has minimum constraint but value is not numeric") + if value < param.minimum: + raise ValueError(f"Config parameter '{param.name}' value {value} < minimum {param.minimum}") + + if param.maximum is not None: + if not isinstance(value, (int, float)): + raise TypeError(f"Config parameter '{param.name}' has maximum constraint but value is not numeric") + if value > param.maximum: + raise ValueError(f"Config parameter '{param.name}' value {value} > maximum {param.maximum}") + + missing_required: List[str] = [] + for p in config_spec.parameters: + if not p.required: + continue + if p.name in bound: + continue + if getattr(p, "default_value", None) is None: + missing_required.append(p.name) + if missing_required: + raise ValueError(f"Missing required config parameters: {missing_required}") + + + def _build_report( + self, + start_ts: float, + status: str, + inputs: List[Data], + outputs: List[Data], + error: Optional[str] = None, + config_profile: Optional[ConfigurationProfile] = None, + ) -> KgTaskReport: + return KgTaskReport( + task=self, + inputs=inputs, + outputs=outputs, + start_ts=start_ts, + duration=time.time() - start_ts, + status=status, + error=error, + config_profile=config_profile, + ) + + def _validate_required_data( + self, + matched: Dict[str, Data], + spec: Mapping[str, Format], + label: str, + raw_items: List[Data], + ) -> None: + if len(matched) == len(spec): + return + + missing = set(spec.keys()) - set(matched.keys()) + expected = {k: v.value for k, v in spec.items()} + available = [f"{obj.path} ({obj.format.value})" for obj in raw_items] + raise ValueError( + f"Missing required {label}: {missing}. " + f"Expected: {expected}. " + f"Available: {available}" + ) + + def _prepare_outputs(self, outputs: Dict[str, Data], stable_files_override: bool) -> None: + if not stable_files_override: + return + for output in outputs.values(): + if output.path.exists(): + if output.path.is_file(): + output.path.unlink() + elif output.path.is_dir(): + shutil.rmtree(output.path) + + def _should_skip(self, outputs: Dict[str, Data]) -> bool: + return all(output.path.exists() for output in outputs.values()) + @staticmethod def _match(data: List[Data], spec: Mapping[str, Format]) -> Dict[str, Data]: """Match data objects to specification by format.""" diff --git a/src/kgpipe/common/models.py b/src/kgpipe/common/models.py index 06a56ec..d3f271a 100644 --- a/src/kgpipe/common/models.py +++ b/src/kgpipe/common/models.py @@ -8,13 +8,15 @@ from __future__ import annotations -from .model.data import Data, DataFormat, DynamicFormat, DataSet, FormatRegistry +from .model.data import Data, DataFormat, DataSet +from .model.default_catalog import BasicDataFormats, CustomDataFormats, BasicTaskCategoryCatalog from .model.task import KgTask, KgTaskReport from .model.pipeline import KgPipe, KgPipePlan, KgPipePlanStep, KgStageReport from .model.evaluation import Metric, EvaluationReport from .model.kg import KG -from .model.task import TaskInput, TaskOutput +from .model.task import TaskInput, TaskOutput, KgTask, KgTaskRun +# from .model.evaluation import KgMetric, KgMetricRun __all__ = [ - "Data", "DataFormat", "DynamicFormat", "DataSet", "FormatRegistry", "KgTask", "KgTaskReport", "KgPipe", "KgPipePlan", "KgPipePlanStep", "KgStageReport", "Metric", "EvaluationReport", "KG", "TaskInput", "TaskOutput" + "Data", "DataFormat", "BasicDataFormats", "CustomDataFormats", "BasicTaskCategoryCatalog", "DataSet", "KgTask", "KgTaskReport", "KgPipe", "KgPipePlan", "KgPipePlanStep", "KgStageReport", "Metric", "EvaluationReport", "KG", "TaskInput", "TaskOutput", "KgTaskRun" ] diff --git a/src/kgpipe/common/registry.py b/src/kgpipe/common/registry.py index d0243e3..f9d24e0 100644 --- a/src/kgpipe/common/registry.py +++ b/src/kgpipe/common/registry.py @@ -2,9 +2,10 @@ from typing import Any, Callable, List, Dict from kgpipe.common.models import KgTask, DataFormat -from kgpipe.common.systemgraph import PipeKG -from kgpipe.common.definitions import MetricEntity +# from kgpipe.common.graph.systemgraph import PipeKG +from kgpipe.common.graph.definitions import MetricEntity, TaskEntity from kgpipe.common.model.configuration import ConfigurationDefinition +from kgpipe.common.graph.mapper import implementation_to_entity # TODO add also to system graph @@ -52,7 +53,7 @@ def decorator(t): description = getattr(obj, 'description', None) type = getattr(obj, 'aspect', None) metric = MetricEntity(name=name, description=description, type=type.value if type else None) - PipeKG.add_metric(metric) + # TODO add to system graph return t return decorator @@ -69,8 +70,11 @@ def task( ) -> Callable[[Callable], KgTask]: def decorator(t): task = KgTask(t.__name__.lower(), input_spec, output_spec, t, description, category, config_spec) + if getattr(t, "_trace_task_run", False): + setattr(task, "trace_task_run", True) cls._registry[f"task:{t.__name__.lower()}"] = task - PipeKG.add_task(task) + # implementation_to_entity(task) + # PipeKG.add_implementation(implementation_to_entity(task)) return task return decorator diff --git a/src/kgpipe/common/systemgraph.py b/src/kgpipe/common/systemgraph.py deleted file mode 100644 index 9c9f95a..0000000 --- a/src/kgpipe/common/systemgraph.py +++ /dev/null @@ -1,354 +0,0 @@ -import functools -import ast -from uuid import uuid4 -from typing import Any, List, TYPE_CHECKING -from pydantic import BaseModel -from datetime import datetime, timezone - -# from kgcore.api import KG, BackendName - -from kgcore.api import KnowledgeGraph, KGEntity, KGRelation, KGProperty, new_id -from kgcore.backend.rdf.rdf_rdflib import RDFLibBackend -from kgcore.backend.rdf.rdf_sparql import RDFSparqlBackend, SparqlAuth -from kgcore.model.rdf.rdf_base import RDFBaseModel - -from kgpipe.common.definitions import ( - TaskEntity, TaskRunEntity, PipelineEntity, PipelineRunEntity, ImplementationEntity, MetricEntity, MetricRunEntity, - MethodEntity, ToolEntity, -) -from kgpipe.common.config import load_config -from kgpipe.common.util import encode_string - -if TYPE_CHECKING: - from kgpipe.common.models import KgTask, KgTaskReport - - -config = load_config() -scheme, rest = config.SYS_KG_URL.split("://") - -backend = RDFLibBackend() -model = RDFBaseModel() - -try: - if scheme == "sparql": - print(f"Using SPARQL backend for system graph: {f"http://{rest}"} with http://github.com/ScaDS/kgpipe/") - backend = RDFSparqlBackend( - endpoint=f"http://{rest}", - update_endpoint=f"http://{rest}", - default_graph="http://github.com/ScaDS/kgpipe/", - auth=SparqlAuth(username=config.SYS_KG_USR, password=config.SYS_KG_PSW)) - else: - raise ValueError(f"Unsupported schema: {scheme}") -except Exception as e: - print(f"Error creating system graph: {e}") - print(f"Using RDFLib memory backend for system graph") - -SYS_KG: KnowledgeGraph = KnowledgeGraph(model=model, backend=backend) - -class PipeKG: - """ - PipeKG is the system graph for the KGpipe framework. - It is a Object Graph Mapper (OGM) for the KGpipe framework. - It is used to store the entities and relations of the KGpipe framework. - """ - - ### Core Layer Entities ### - - @staticmethod - def add_task(task: "KgTask"): - from kgpipe.common.models import KgTask # Import here to avoid circular import - types = [config.ONTOLOGY_PREFIX+encode_string(c) for c in task.category] - properties = [] - properties.append(KGProperty(key="description", value=task.description)) - task_entity = SYS_KG.create_entity(id=config.PIPEKG_PREFIX+task.name+"Impl", types=types+[config.ONTOLOGY_PREFIX+"Implementation"], properties=properties) - for input_name, input_format in task.input_spec.items(): - input_entity = SYS_KG.create_entity(id=config.PIPEKG_PREFIX+task.name+"Impl_"+input_name, types=[config.ONTOLOGY_PREFIX+"Data"], properties={ - "format": input_format, - }) - SYS_KG.create_relation(type="input", source=task_entity.id, target=input_entity.id) - for output_name, output_format in task.output_spec.items(): - output_entity = SYS_KG.create_entity(id=config.PIPEKG_PREFIX+task.name+"Impl_"+output_name, types=[config.ONTOLOGY_PREFIX+"Data"], properties={ - "format": output_format, - }) - SYS_KG.create_relation(type="output", source=task_entity.id, target=output_entity.id) - - @staticmethod - def list_taskImplementations(self) -> List[ImplementationEntity]: - entities = SYS_KG.find_entities(types=[config.ONTOLOGY_PREFIX + "Implementation"]) - implementations: List[ImplementationEntity] = [] - - for entity in entities: - name_value = self._prop_value(entity.properties, "name", config.ONTOLOGY_PREFIX + "name") - if not name_value: - # Fallback: derive a readable name from implementation IRI. - name_value = str(entity.id).rstrip("/").split("/")[-1] - - implements_method_value = self._prop_value( - entity.properties, - "implementsMethod", - config.ONTOLOGY_PREFIX + "implementsMethod", - ) - uses_tool_value = self._prop_value( - entity.properties, - "usesTool", - config.ONTOLOGY_PREFIX + "usesTool", - ) - has_parameter_value = self._prop_value( - entity.properties, - "hasParameter", - config.ONTOLOGY_PREFIX + "hasParameter", - ) - - input_entities = SYS_KG.get_neighbors(entity.id, predicate="input") - output_entities = SYS_KG.get_neighbors(entity.id, predicate="output") - - def get_property_values(properties: list[KGProperty], key: str) -> list[str]: - return [prop.value for prop in properties if prop.key.endswith(key)] - - input_spec = [get_property_values(input_entity.properties, "format")[0] for input_entity in input_entities] - output_spec = [get_property_values(output_entity.properties, "format")[0] for output_entity in output_entities] - - implementations.append( - ImplementationEntity( - uri=str(entity.id), - name=str(name_value), - input_spec=input_spec, - output_spec=output_spec, - implementsMethod=self._to_list(implements_method_value), - hasParameter=self._to_list(has_parameter_value), - usesTool=self._to_list(uses_tool_value), - ) - ) - - return implementations - - @staticmethod - def add_method(method: MethodEntity): pass - - @staticmethod - def find_method(name: str) -> MethodEntity: pass - - @staticmethod - def add_tool(tool: ToolEntity): pass - - @staticmethod - def find_tool(name: str) -> ToolEntity: pass - - @staticmethod - def find_implementation(): pass - - @staticmethod - def add_implementation(implementation: ImplementationEntity): - SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"Implementation"], properties={ - "name": implementation.name, - "usesTool": implementation.usesTool, - "implementsMethod": implementation.implementsMethod, - "interface": implementation.interface, - - }) - - @staticmethod - def find_implementation(name: str) -> KGEntity: - return SYS_KG.read_entity(id=config.PIPEKG_PREFIX+name, types=[config.ONTOLOGY_PREFIX+"Implementation"])[0] - - ### Data Layer Entities ### - def add_data_artifact(): pass - def add_data_artifact_type(): pass - def add_data_artifact_spec(): pass - def find_data_artifact(): pass - def find_data_artifact_type(): pass - def find_data_artifact_spec(): pass - - ### Pipeline Layer Entities ### - - @staticmethod - def add_pipeline(pipeline: PipelineEntity): - SYS_KG.create_entity(id=new_id(),types=["Pipeline"], properties={ - "tasks": pipeline.tasks, - "input": pipeline.input, - "output": pipeline.output, - }) - - def find_pipeline(): pass - def add_pipeline_step(): pass - def find_pipeline_step(): pass - def add_pipeline_definition(): pass - def find_pipeline_definition(): pass - - ### Evaluation Layer Entities ### - - @staticmethod - def add_metric(metric: MetricEntity): - SYS_KG.create_entity(id=config.PIPEKG_PREFIX+encode_string(metric.name),types=[config.ONTOLOGY_PREFIX+"Metric"], properties={ - config.ONTOLOGY_PREFIX+"name": metric.name, - config.ONTOLOGY_PREFIX+"description": metric.description, - config.ONTOLOGY_PREFIX+"type": metric.type, - # "input": metric.input, - # "output": metric.output, - }) - - @staticmethod - def find_metric(metric_name: str) -> MetricEntity: - pass - - ### Run Layer Entities ### - # def add_task_run(): pass - # def find_task_run(): pass - # def add_pipeline_run(): pass - # def find_pipeline_run(): pass - # def add_metric_run(): pass - # def find_metric_run(): pass - - @staticmethod - def add_task_run(task_run: TaskRunEntity): - # SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"TaskReport"], properties={ - # "task": task_run.task_name, - # "input": [data.path for data in task_run.inputs], - # "output": [data.path for data in task_run.outputs], - # "status": task_run.status, - # "duration": task_run.duration, - # "error": task_run.error, - # }) - pass - - @staticmethod - def add_pipeline_run(pipeline_run: PipelineRunEntity): - pipeline_run_entity = SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"PipelineRun"], properties={ - "name": pipeline_run.name, - "status": pipeline_run.status, - "started_at": pipeline_run.started_at, - "ended_at": pipeline_run.ended_at - }) - for idx, task_run in enumerate(pipeline_run.hasTaskRun): - task_run_entity = SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"TaskRun"], properties={ - "number": idx, - "name": task_run.name, - "status": task_run.status, - "started_at": task_run.started_at, - "ended_at": task_run.ended_at, - }) - SYS_KG.create_relation(type="executesTask", source=task_run_entity.id, target=task_run.executesTask) - SYS_KG.create_relation(type="usesImplementation", source=task_run_entity.id, target=task_run.usesImplementation) - SYS_KG.create_relation(type=config.ONTOLOGY_PREFIX+"hasTaskRun", source=pipeline_run_entity.id, target=task_run_entity.id) - - # return pipeline_run_entity - - # @staticmethod - # def add_pipeline_result(pipeline_result: PipelineResult): - # SYS_KG.create_entity(id=new_id(),types=["PipelineResult"], properties={ - # "task_results": pipeline_result.task_results, - # "eval_results": pipeline_result.eval_results, - # "input": pipeline_result.input, - # "output": pipeline_result.output, - # }) - - @staticmethod - def add_metric_run(metric_run: MetricRunEntity): - metric_run_entity = SYS_KG.create_entity(id=new_id(),types=[config.ONTOLOGY_PREFIX+"MetricRun"], properties={ - config.ONTOLOGY_PREFIX+"status": metric_run.status, - config.ONTOLOGY_PREFIX+"started_at": metric_run.started_at, - config.ONTOLOGY_PREFIX+"ended_at": metric_run.ended_at, - config.ONTOLOGY_PREFIX+"value": metric_run.value, - config.ONTOLOGY_PREFIX+"details": metric_run.details, - config.ONTOLOGY_PREFIX+"input": metric_run.input[0].uri, - }) - SYS_KG.create_relation(type=config.ONTOLOGY_PREFIX+"computedMetric", source=metric_run_entity.id, target=metric_run.computedMetric) - - ### Parameter Layer Entities ### - # def add_parameter(): pass - # def find_parameter(): pass - # def add_parameter_binding(): pass - # def find_parameter_binding(): pass - - ### Utility Functions ### - - @staticmethod - def sparql_construct(query: str): - backend : RDFSparqlBackend = SYS_KG.backend - result = backend.query_sparql(query) - return result - - @staticmethod - def _prop_value(properties: List[KGProperty], *keys: str) -> Any: - """Find a property value by exact key or key suffix.""" - for prop in properties: - if prop.key in keys: - return prop.value - for prop in properties: - for key in keys: - if prop.key.endswith(key): - return prop.value - return None - - @staticmethod - def _to_list(value: Any) -> List[str]: - """Normalize KG property values to list[str].""" - if value is None: - return [] - if isinstance(value, list): - return [str(v) for v in value] - if isinstance(value, tuple): - return [str(v) for v in value] - if isinstance(value, str): - text = value.strip() - if not text: - return [] - # Stored literals may contain Python-list string repr. - if text.startswith("[") and text.endswith("]"): - try: - parsed = ast.literal_eval(text) - except (ValueError, SyntaxError): - return [text] - if isinstance(parsed, list): - return [str(v) for v in parsed] - return [text] - return [str(value)] - -# def Track(_cls=None, *, with_timestamp: bool = False): -# """ -# Use as: -# @Track -# @Track(with_timestamp=True) -# """ -# def decorator(cls): -# class Tracked(cls): # subclass the original class -# def __init__(self, *args: Any, **kwargs: Any): -# super().__init__(*args, **kwargs) - -# inst_id = f"{cls.__name__}:{uuid4().hex[:8]}" -# setattr(self, "_kg_id", inst_id) - -# if isinstance(self, BaseModel): -# props = self.model_dump() -# else: -# props = {k: v for k, v in vars(self).items() if not k.startswith("_")} - -# if with_timestamp: -# props["timestamp"] = datetime.now(timezone.utc).isoformat() - -# SYS_KG.create_entity([cls.__name__], id=inst_id, props=props) - -# Tracked.__name__ = cls.__name__ # optional cosmetics -# Tracked.__qualname__ = cls.__qualname__ -# Tracked.__doc__ = cls.__doc__ -# return Tracked - -# return decorator if _cls is None else decorator(_cls) - -# def kg_function(fn): -# @functools.wraps(fn) -# def wrapper(*args, **kwargs): -# result = fn(*args, **kwargs) -# call_id = f"{fn.__name__}:{uuid4().hex[:8]}" -# SYS_KG.create_entity( -# ["FunctionCall"], -# id=call_id, -# props={ -# "name": fn.__name__, -# # Be careful serializing args/kwargs; this is a toy example: -# "args": repr(args), -# "kwargs": repr(kwargs), -# }, -# ) -# return result -# return wrapper From 86a0e8182dc2d9fca55533d4d0365330ecc58704 Mon Sep 17 00:00:00 2001 From: Marvin Date: Tue, 31 Mar 2026 22:06:08 +0200 Subject: [PATCH 05/42] feat(eval): init eval api changes --- src/kgpipe/cli/list.py | 20 +- src/kgpipe/common/model/kg.py | 13 +- src/kgpipe/common/model/task.py | 28 ++- .../aspects/func/integration_eval.py | 30 ++- src/kgpipe/evaluation/aspects/reference.py | 8 + src/kgpipe/evaluation/aspects/statistical.py | 6 + src/kgpipe/evaluation/base.py | 21 +- src/kgpipe/test/common/test_graph.py | 41 ++++ src/kgpipe/test/common/test_model.py | 93 ++++++--- src/kgpipe/test/common/test_runtime_to_kg.py | 83 ++++++++ src/kgpipe/test/common/test_systemgraph.py | 189 ++++++++++++------ .../test/common/test_task_category_catalog.py | 33 +++ src/kgpipe/test/common/test_task_model.py | 136 +++++++++++++ 13 files changed, 575 insertions(+), 126 deletions(-) create mode 100644 src/kgpipe/test/common/test_graph.py create mode 100644 src/kgpipe/test/common/test_runtime_to_kg.py create mode 100644 src/kgpipe/test/common/test_task_category_catalog.py create mode 100644 src/kgpipe/test/common/test_task_model.py diff --git a/src/kgpipe/cli/list.py b/src/kgpipe/cli/list.py index df0ad90..daa3a6f 100644 --- a/src/kgpipe/cli/list.py +++ b/src/kgpipe/cli/list.py @@ -45,14 +45,18 @@ def show_registered_tasks(format: str = "table") -> None: tasks = get_registered_tasks() for task in tasks: - table.add_row( - task.name, - ", ".join(getattr(task, 'category', [])), - getattr(task, 'description', 'N/A'), - str(getattr(task, 'input_spec', 'N/A')), - str(getattr(task, 'output_spec', 'N/A')), - "/".join(function_location(task.function).split(".")[:-1]) - ) + try: + table.add_row( + task.name, + ", ".join(getattr(task, 'category', [])), + getattr(task, 'description', 'N/A'), + str(getattr(task, 'input_spec', 'N/A')), + str(getattr(task, 'output_spec', 'N/A')), + "/".join(function_location(task.function).split(".")[:-1]) + ) + except Exception as e: + print(f"Error adding task {task.name}: {e}") + continue if format == "table": console.print(table) diff --git a/src/kgpipe/common/model/kg.py b/src/kgpipe/common/model/kg.py index 9e08c58..42bdc18 100644 --- a/src/kgpipe/common/model/kg.py +++ b/src/kgpipe/common/model/kg.py @@ -68,12 +68,13 @@ def __str__(self) -> str: # TODO wip class for central KgPipe KG entity +@dataclass class KgKg: """Represents a KG for the KgPipe framework.""" - # data: List[KgData] + graph_data: KgData + ontology_data: KgData # provenance: str - - @staticmethod - def load_from_plan(plan: KgPipePlan) -> KG: - pass - pass \ No newline at end of file + # @staticmethod + # def load_from_plan(plan: KgPipePlan) -> KG: + # pass + # pass \ No newline at end of file diff --git a/src/kgpipe/common/model/task.py b/src/kgpipe/common/model/task.py index e901aa9..6f45559 100644 --- a/src/kgpipe/common/model/task.py +++ b/src/kgpipe/common/model/task.py @@ -4,7 +4,7 @@ # import field from dataclasses import dataclass, field from .data import Data, Format, DataFormat -from pydantic import BaseModel +from pydantic import BaseModel, Field, ConfigDict, model_validator import time import shutil from uuid import uuid4 @@ -26,7 +26,12 @@ class KgTaskReport(BaseModel): """Report of a task execution.""" - task: "KgTask" + model_config = ConfigDict(arbitrary_types_allowed=True) + + # Backwards-compatible identifier for persisted reports (`exec-report.json`). + # Historically we stored only the task name; newer runtime code may also attach the `KgTask`. + task_name: str + task: Optional["KgTask"] = Field(default=None, exclude=True) inputs: List[Data] outputs: List[Data] start_ts: float @@ -35,6 +40,24 @@ class KgTaskReport(BaseModel): error: Optional[str] = None config_profile: Optional[ConfigurationProfile] = None + @model_validator(mode="before") + @classmethod + def _coerce_task_fields(cls, data): + """ + Accept both legacy reports (with `task_name`) and new runtime reports (with `task`). + """ + if not isinstance(data, dict): + return data + + # If we have a task object but no explicit task_name, derive it. + if "task_name" not in data and "task" in data and data["task"] is not None: + task_obj = data["task"] + name = getattr(task_obj, "name", None) + if name is not None: + data["task_name"] = name + + return data + KgTaskRun = KgTaskReport class TaskStatus(Enum): @@ -243,6 +266,7 @@ def _build_report( ) -> KgTaskReport: return KgTaskReport( task=self, + task_name=self.name, inputs=inputs, outputs=outputs, start_ts=start_ts, diff --git a/src/kgpipe/evaluation/aspects/func/integration_eval.py b/src/kgpipe/evaluation/aspects/func/integration_eval.py index 72b6288..0e5392b 100644 --- a/src/kgpipe/evaluation/aspects/func/integration_eval.py +++ b/src/kgpipe/evaluation/aspects/func/integration_eval.py @@ -3,11 +3,11 @@ from pathlib import Path import pandas as pd from rdflib import RDFS, URIRef, Graph, RDF -from dataclasses import dataclass +from dataclasses import dataclass, field from kgpipe.util.embeddings.st_emb import get_model import numpy as np -from kgpipe.datasets.multipart_multisource import read_entities_csv - +from kgpipe.datasets.multipart_multisource import read_entities_csv, EntitiesRow +from typing import Any # model # entity dict @@ -41,6 +41,7 @@ class BinaryClassificationResult: fp: int tn: int fn: int + details: dict[str, Any] = field(default_factory=dict) def accuracy(self) -> float: return (self.tp + self.tn) / (self.tp + self.tn + self.fp + self.fn) @@ -63,6 +64,7 @@ def __dict__(self): "fp": self.fp, "tn": self.tn, "fn": self.fn, + "details": self.details, "accuracy": self.accuracy(), "precision": self.precision(), "recall": self.recall(), @@ -102,7 +104,7 @@ def load_entity_dict_from_csv(path: Path, delimiter: str = ",") -> dict: return entity_dict -def load_entity_dict(path: Path) -> dict: +def load_entity_dict(path: Path) -> dict[str, EntitiesRow]: """ """ if path.name.endswith(".json"): @@ -244,8 +246,9 @@ def evaluate_source_typed_entity_coverage(kg: KG, entity_dict_path: Path) -> Ent """ checks expected & integrated source typed entity overlap using label embeddings """ - model = get_model() - entity_dict = load_entity_dict(entity_dict_path) + model = get_model() # TODO this is not used here... + # TODO we need to substract the seed from the found entities... + entity_dict: dict[str, EntitiesRow] = load_entity_dict(entity_dict_path) expected_entity_label_type_pairs = [] @@ -270,15 +273,22 @@ def evaluate_source_typed_entity_coverage(kg: KG, entity_dict_path: Path) -> Ent found_eltp = set(found_entity_label_type_pairs) expected_eltp = set(expected_entity_label_type_pairs) - tp_set = found_eltp & expected_eltp - fp_set = found_eltp - expected_eltp - fn_set = expected_eltp - found_eltp + tp_set = found_eltp & expected_eltp # correct entity type pair + fp_set = found_eltp - expected_eltp # wrong entity type pair + fn_set = expected_eltp - found_eltp # missing entity type pair return BinaryClassificationResult( tp=len(tp_set), fp=len(fp_set), fn=len(fn_set), - tn=0 + tn=0, + details={ + "found_entity_label_type_pairs": found_entity_label_type_pairs, + "expected_entity_label_type_pairs": expected_entity_label_type_pairs, + "tp_set": len(tp_set), + "fp_set": len(fp_set), + "fn_set": len(fn_set) + } ) def evaluate_reference_triple_alignment(kg: KG, reference_kg: KG) -> TripleAlignmentResult: diff --git a/src/kgpipe/evaluation/aspects/reference.py b/src/kgpipe/evaluation/aspects/reference.py index b27a934..4b474b9 100644 --- a/src/kgpipe/evaluation/aspects/reference.py +++ b/src/kgpipe/evaluation/aspects/reference.py @@ -422,8 +422,14 @@ def compute(self, kg: KG, config: ReferenceConfig, **kwargs) -> MetricResult: result = evaluate_source_typed_entity_coverage(kg, verified_source_entities_path) + # log details to file + with open("source_typed_entity_coverage_details.json", "w") as f: + json.dump(result.__dict__(), f) + return MetricResult( name=self.name, + kg=kg, + metric=self, value=result.f1_score(), normalized_score=result.f1_score(), details=result.__dict__(), @@ -714,6 +720,8 @@ def evaluate(self, kg: KG, config: Optional[ReferenceConfig] = None, metrics: Op print(traceback.format_exc()) error_result = MetricResult( name=metric.name, + kg=kg, + metric=metric, value=0.0, normalized_score=0.0, details={"error": str(e)}, diff --git a/src/kgpipe/evaluation/aspects/statistical.py b/src/kgpipe/evaluation/aspects/statistical.py index 1e5aac6..c1bd9a0 100644 --- a/src/kgpipe/evaluation/aspects/statistical.py +++ b/src/kgpipe/evaluation/aspects/statistical.py @@ -71,6 +71,9 @@ def compute(self, kg: KG, config: StatisticalConfig, **kwargs) -> MetricResult: except Exception as e: print("this exception is raised") return MetricResult( + metric=self, + started_at=time.time(), + kg=kg, name=self.name, value=0.0, normalized_score=0.0, @@ -446,7 +449,10 @@ def evaluate(self, kg: KG, metrics: Optional[List[str]] = None, config: Optional except Exception as e: # Create error result error_result = MetricResult( + metric=metric, + kg=kg, name=metric.name, + started_at=time.time(), value=0.0, normalized_score=0.0, details={"error": str(e)}, diff --git a/src/kgpipe/evaluation/base.py b/src/kgpipe/evaluation/base.py index e5c1675..5a91d0b 100644 --- a/src/kgpipe/evaluation/base.py +++ b/src/kgpipe/evaluation/base.py @@ -9,7 +9,7 @@ from enum import Enum from typing import Any, Dict, List, Optional # from kgpipe.common.systemgraph import kg_class -from kgpipe.common.systemgraph import PipeKG +from kgpipe.common.graph.systemgraph import PipeKG import time import json import functools @@ -18,10 +18,11 @@ from pydantic import BaseModel from kgpipe.common.models import KG -from kgpipe.common.definitions import MetricEntity, MetricRunEntity, MetricEntityId, DataHandle +from kgpipe.common.graph.definitions import MetricRunEntity, MetricEntityId from kgpipe.common.config import config from pathlib import Path from kgpipe.common.util import encode_string + class EvaluationAspect(Enum): """The three main aspects of KG evaluation.""" STATISTICAL = "statistical" @@ -76,19 +77,19 @@ def __str__(self) -> str: # @Track(with_timestamp=True) # @kg_class(type="MetricResult", description="Result of computing a single metric.") -class MetricResult(BaseModel): +@dataclass +class MetricResult: """Result of computing a single metric.""" name: str + metric: "Metric" value: float normalized_score: float # 0.0-1.0 range - details: Dict[str, Any] aspect: EvaluationAspect + kg: KG + started_at: float = field(default_factory=time.time) + ended_at: float = field(default_factory=time.time) + details: Dict[str, Any] = field(default_factory=dict) duration: float = 0.0 - input: str = "" # TODO - - def __post_init__(self): - if not 0.0 <= self.normalized_score <= 1.0: - raise ValueError("Normalized score must be between 0.0 and 1.0") class MetricConfig(BaseModel): name: str @@ -117,7 +118,7 @@ def save_metric_run(metric: MetricResult): started_at=time.time(), ended_at=time.time(), computedMetric=MetricEntityId(config.PIPEKG_PREFIX+encode_string(metric.name)), - input=[DataHandle(uri=metric.input, type="any/text")], + input=[], # [Data(uri=metric.kg.path, type="any/text")], value=metric.value, details=json.dumps(metric.details, default=str) ) diff --git a/src/kgpipe/test/common/test_graph.py b/src/kgpipe/test/common/test_graph.py new file mode 100644 index 0000000..e989caf --- /dev/null +++ b/src/kgpipe/test/common/test_graph.py @@ -0,0 +1,41 @@ +from uuid import uuid4 + +from kgpipe.common.graph.definitions import ( + DataTypeEntity, + DataSpecEntity, + TaskEntity, + ImplementationEntity, +) +from kgpipe.common.graph.systemgraph import PipeKG + + +def _uid(prefix: str) -> str: + return f"{prefix}_{uuid4().hex[:8]}" + + +def test_add_implementation_and_find_implemenetation(): + task = TaskEntity(name=_uid("task"), description="test task") + task_id = PipeKG.add_task(task) + + data_type = DataTypeEntity(format="text/csv", data_schema=_uid("schema")) + data_type_id = PipeKG.add_data_type(data_type) + + in_spec_id = PipeKG.add_data_spec(DataSpecEntity(name=_uid("in_spec"), data_type=data_type_id)) + out_spec_id = PipeKG.add_data_spec(DataSpecEntity(name=_uid("out_spec"), data_type=data_type_id)) + + impl_name = _uid("impl") + impl = ImplementationEntity( + name=impl_name, + version="0.0.1", + input_spec=[in_spec_id], + output_spec=[out_spec_id], + realizesTask=[task_id], + usesTool=[], + ) + + PipeKG.add_implementation(impl) + found = PipeKG.find_implementation(impl_name) + + assert found is not None + assert found.name == impl_name + assert found.version == "0.0.1" diff --git a/src/kgpipe/test/common/test_model.py b/src/kgpipe/test/common/test_model.py index c7b73c0..d579f5b 100644 --- a/src/kgpipe/test/common/test_model.py +++ b/src/kgpipe/test/common/test_model.py @@ -1,29 +1,68 @@ -from kgpipe.common.models import KgPipePlan, KgPipePlanStep, Data, DataFormat -from pathlib import Path import json +from enum import Enum +from pathlib import Path + +import pytest + +from kgpipe.common.models import ( + BasicDataFormats, + CustomDataFormats, + Data, + DataFormat, + KgPipePlan, + KgPipePlanStep, +) + +class ProjectFormats(CustomDataFormats): + EMBEDDINGS_JSON = "embeddings.json" + + +class ForeignFormats(str, Enum): + MY_RAW = "my.raw" + + +def test_kg_pipe_plan_roundtrip(): + plan = KgPipePlan( + steps=[ + KgPipePlanStep( + task="paris_entity_matching", + input=[Data(path=Path("data.nt"), format=DataFormat.RDF_NTRIPLES)], + output=[Data(path=Path("data.paris_csv"), format=DataFormat.PARIS_CSV)], + ), + KgPipePlanStep( + task="paris_csv_to_matching_format", + input=[Data(path=Path("data.paris_csv"), format=DataFormat.PARIS_CSV)], + output=[Data(path=Path("data.em_json"), format=DataFormat.ER_JSON)], + ), + ], + seed=Data(path=Path("seed.nt"), format=DataFormat.RDF_NTRIPLES), + source=Data(path=Path("source.nt"), format=DataFormat.RDF_NTRIPLES), + result=Data(path=Path("result.nt"), format=DataFormat.RDF_NTRIPLES), + ) + + plan_json = plan.model_dump_json() + plan_back = KgPipePlan(**json.loads(plan_json)) + + assert plan == plan_back + + +def test_data_accepts_basic_data_formats(): + data = Data(path=Path("a.nt"), format=BasicDataFormats.RDF_NTRIPLES) + assert data.format == BasicDataFormats.RDF_NTRIPLES + assert data.to_dict()["format"] == "nt" + + +def test_data_accepts_custom_data_formats(): + data = Data(path=Path("embed.json"), format=ProjectFormats.EMBEDDINGS_JSON) + assert data.format == ProjectFormats.EMBEDDINGS_JSON + assert data.to_dict()["format"] == "embeddings.json" + + +def test_data_rejects_foreign_string_enum_not_based_on_custom_catalog(): + with pytest.raises(ValueError): + Data(path=Path("x.raw"), format=ForeignFormats.MY_RAW) + -def test_kg_pipe_plan(): - plan = KgPipePlan( - steps=[ - KgPipePlanStep( - task="paris_entity_matching", - input=[Data(path=Path("data.nt"), format=DataFormat.RDF_NTRIPLES)], - output=[Data(path=Path("data.paris_csv"), format=DataFormat.PARIS_CSV)] - ), - KgPipePlanStep( - task="paris_csv_to_matching_format", - input=[Data(path=Path("data.paris_csv"), format=DataFormat.PARIS_CSV)], - output=[Data(path=Path("data.em_json"), format=DataFormat.ER_JSON)] - ), - ], - seed=Data(path=Path("seed.nt"), format=DataFormat.RDF_NTRIPLES), - source=Data(path=Path("source.nt"), format=DataFormat.RDF_NTRIPLES), - result=Data(path=Path("result.nt"), format=DataFormat.RDF_NTRIPLES), - ) - - plan_json = plan.model_dump_json() - print(plan_json) - - plan_back = KgPipePlan(**json.loads(plan_json)) - - assert plan == plan_back \ No newline at end of file +def test_data_rejects_unknown_string_format(): + with pytest.raises(ValueError, match="Unknown format: does-not-exist"): + Data(path=Path("x.any"), format="does-not-exist") \ No newline at end of file diff --git a/src/kgpipe/test/common/test_runtime_to_kg.py b/src/kgpipe/test/common/test_runtime_to_kg.py new file mode 100644 index 0000000..a716642 --- /dev/null +++ b/src/kgpipe/test/common/test_runtime_to_kg.py @@ -0,0 +1,83 @@ +from pathlib import Path + +from kgpipe.common.config import config +from kgpipe.common.models import Data, DataFormat, KgTaskReport +from kgpipe.common.model.task import KgTask +from kgpipe.common.runtime_to_kg import ( + data_to_handle, + reports_to_pipeline_run_entity, + task_to_task_entity, + task_report_to_task_run_entity, +) + + +def _make_report(name: str, start_ts: float, duration: float, status: str = "success") -> KgTaskReport: + return KgTaskReport( + task_name=name, + inputs=[Data(path=Path(f"{name}.in.nt"), format=DataFormat.RDF_NTRIPLES)], + outputs=[Data(path=Path(f"{name}.out.nt"), format=DataFormat.RDF_NTRIPLES)], + start_ts=start_ts, + duration=duration, + status=status, + ) + + +def test_data_to_handle_maps_path_and_format(): + data = Data(path=Path("test.nt"), format=DataFormat.RDF_NTRIPLES) + handle = data_to_handle(data) + assert handle.uri == "test.nt" + assert handle.type == DataFormat.RDF_NTRIPLES + + +def test_task_to_task_entity_maps_name_and_defaults(): + task = KgTask( + name="normalize", + input_spec={"in": DataFormat.RDF_NTRIPLES}, + output_spec={"out": DataFormat.RDF_NTRIPLES}, + function=lambda _i, _o: None, + ) + entity = task_to_task_entity(task) + assert entity.name == "normalize" + assert entity.hasSubtask == [] + + +def test_task_report_to_task_run_entity_maps_core_fields(): + report = _make_report("normalize", start_ts=10.0, duration=2.5) + entity = task_report_to_task_run_entity(report, index=3) + + assert entity.number == 3 + assert entity.name == "normalize" + assert entity.status == "success" + assert entity.started_at == 10.0 + assert entity.ended_at == 12.5 + assert str(entity.executesTask) == f"{config.PIPEKG_PREFIX}normalize" + assert str(entity.usesImplementation) == f"{config.PIPEKG_PREFIX}normalizeImpl" + assert len(entity.input) == 1 + assert len(entity.output) == 1 + + +def test_reports_to_pipeline_run_entity_aggregates_times_and_runs(): + reports = [ + _make_report("step_a", start_ts=100.0, duration=10.0), + _make_report("step_b", start_ts=80.0, duration=5.0), + ] + + pipeline_entity = reports_to_pipeline_run_entity(reports, pipeline_name="demo_pipe") + + assert pipeline_entity.name == "demo_pipe" + assert pipeline_entity.status == "success" + assert pipeline_entity.started_at == 80.0 + assert pipeline_entity.ended_at == 110.0 + assert len(pipeline_entity.hasTaskRun) == 2 + assert pipeline_entity.hasTaskRun[0].number == 0 + assert pipeline_entity.hasTaskRun[1].number == 1 + + +def test_reports_to_pipeline_run_entity_handles_empty_reports(): + pipeline_entity = reports_to_pipeline_run_entity([], pipeline_name="empty_pipe") + + assert pipeline_entity.name == "empty_pipe" + assert pipeline_entity.status == "success" + assert pipeline_entity.started_at == 0.0 + assert pipeline_entity.ended_at == 0.0 + assert pipeline_entity.hasTaskRun == [] diff --git a/src/kgpipe/test/common/test_systemgraph.py b/src/kgpipe/test/common/test_systemgraph.py index 870bd75..2be56c7 100644 --- a/src/kgpipe/test/common/test_systemgraph.py +++ b/src/kgpipe/test/common/test_systemgraph.py @@ -1,72 +1,135 @@ -from kgpipe.common.systemgraph import kg_class, kg_function, SYS_KG, add_task, add_task_result, add_pipeline, add_pipeline_result -from kgpipe.common.definitions import Task, Eval, Pipeline, TaskResult, DataHandle, PipelineResult -import sys -from kgcore.backend.rdf.rdf_rdflib import RDFLibBackend - -task1 = Task( - name="test_task", - type="test_type", - description="test_description", - input=["test_input"], - output=["test_output"] -) -task2 = Task( - name="test_task2", - type="test_type2", - description="test_description2", - input=["test_input2"], - output=["test_output2"] -) -task_result1 = TaskResult( - task=task1, - config={"test_config": "test_config"}, - input=[DataHandle(uri="test_input", type="test_input_type")], - output=[DataHandle(uri="test_output", type="test_output_type")], - status="test_status", - duration=10.0 -) -task_result2 = TaskResult( - task=task2, - config={"test_config2": "test_config2"}, - input=[DataHandle(uri="test_input2", type="test_input_type2")], - output=[DataHandle(uri="test_output2", type="test_output_type2")], - status="test_status2", - duration=20.0 -) -pipeline = Pipeline( - tasks=[task1, task2], - input=["test_input"], - output=["test_output"] -) -pipeline_result = PipelineResult( - task_results=[task_result1, task_result2], - eval_results=[], - input=[DataHandle(uri="test_input", type="test_input_type")], - output=[DataHandle(uri="test_output", type="test_output_type")], - status="test_status", - duration=30.0 +from uuid import uuid4 + +from kgpipe.common.definitions import ( + DataHandle, + ImplementationEntity, + MethodEntity, + MetricEntity, + PipelineEntity, + TaskRunEntity, + ToolEntity, ) +from kgpipe.common.systemgraph import PipeKG + + +def _uid(prefix: str) -> str: + return f"{prefix}_{uuid4().hex[:8]}" + + +def test_core_layer_method_tool_and_implementation(): + method_name = _uid("method") + tool_name = _uid("tool") + impl_name = _uid("impl") + + method = MethodEntity(name=method_name, realizesTask=["task:a"]) + tool = ToolEntity(name=tool_name, providesMethods=["method:a"]) + implementation = ImplementationEntity( + name=impl_name, + input_spec=["text/csv"], + output_spec=["application/json"], + implementsMethod=["method:a"], + hasParameter=["param:a"], + usesTool=["tool:a"], + ) + + PipeKG.add_method(method) + PipeKG.add_tool(tool) + PipeKG.add_implementation(implementation) + + found_method = PipeKG.find_method(method_name) + found_tool = PipeKG.find_tool(tool_name) + found_implementation = PipeKG.find_implementation(impl_name) + + assert found_method is not None + assert found_method.name == method_name + assert "task:a" in found_method.realizesTask + + assert found_tool is not None + assert found_tool.name == tool_name + assert "method:a" in found_tool.providesMethods + + assert found_implementation is not None + assert found_implementation.name == impl_name + assert found_implementation.input_spec == ["text/csv"] + assert found_implementation.output_spec == ["application/json"] + + +def test_data_layer_artifact_type_and_spec(): + artifact_uri = f"file:///{_uid('artifact')}.csv" + artifact_type = _uid("artifact_type") + spec_name = _uid("spec") + specification = '{"type":"object","properties":{"name":{"type":"string"}}}' + data = DataHandle( + uri=artifact_uri, + type="text/csv", + version="1.0.0", + hash="abc123", + size=42, + ) + + PipeKG.add_data_artifact(data) + PipeKG.add_data_artifact_type(artifact_type) + PipeKG.add_data_artifact_spec(spec_name, specification) + + found_data = PipeKG.find_data_artifact(artifact_uri) + found_type = PipeKG.find_data_artifact_type(artifact_type) + found_spec = PipeKG.find_data_artifact_spec(spec_name) + + assert found_data is not None + assert found_data.uri == artifact_uri + assert found_data.type == "text/csv" + assert found_data.version == "1.0.0" + assert found_type == artifact_type + assert found_spec == specification + + +def test_pipeline_layer_pipeline_step_and_definition(): + pipeline_name = _uid("pipeline") + step_task = "task:clean" + definition_name = _uid("pipeline_def") + pipeline_id = f"pipeline:{pipeline_name}" + + pipeline = PipelineEntity(name=pipeline_name, tasks=[step_task], input=[], output=[]) + PipeKG.add_pipeline(pipeline) + PipeKG.add_pipeline_step(pipeline_name=pipeline_name, step_number=1, task_id=step_task) + PipeKG.add_pipeline_definition(name=definition_name, pipeline_id=pipeline_id) + + found_pipeline = PipeKG.find_pipeline(pipeline_name) + found_step = PipeKG.find_pipeline_step(pipeline_name, 1) + found_definition = PipeKG.find_pipeline_definition(definition_name) -model: RDFLibBackend = SYS_KG.backend + assert found_pipeline is not None + assert found_pipeline.name == pipeline_name + assert step_task in found_pipeline.tasks + assert found_step is not None + assert found_definition is not None -def test_task_entity(): - add_task(task1) - add_task(task2) - # print(model.get_rdflibgraph().serialize(format="turtle")) +def test_metrics_layer_add_and_find_metric(): + metric_name = _uid("metric") + metric = MetricEntity(name=metric_name, description="Accuracy metric", type="score") + PipeKG.add_metric(metric) -def test_task_result_entity(): - add_task_result(task_result1) - add_task_result(task_result2) + found_metric = PipeKG.find_metric(metric_name) - # print(model.get_rdflibgraph().serialize(format="turtle")) + assert found_metric is not None + assert found_metric.name == metric_name + assert found_metric.description == "Accuracy metric" + assert found_metric.type == "score" -def test_pipeline_entity(): - add_pipeline(pipeline) - # print(model.get_rdflibgraph().serialize(format="turtle")) +def test_run_layer_add_task_run(): + task_run = TaskRunEntity( + number=1, + name=_uid("task_run"), + status="success", + started_at=1.0, + ended_at=2.0, + input=[DataHandle(uri="file:///in.csv", type="text/csv")], + output=[DataHandle(uri="file:///out.csv", type="text/csv")], + executesTask="task:clean", + usesImplementation="impl:clean_v1", + hasParameterBinding=[], + ) -def test_pipeline_result_entity(): - add_pipeline_result(pipeline_result) - - print(model.get_rdflibgraph().serialize(format="turtle")) \ No newline at end of file + PipeKG.add_task_run(task_run) \ No newline at end of file diff --git a/src/kgpipe/test/common/test_task_category_catalog.py b/src/kgpipe/test/common/test_task_category_catalog.py new file mode 100644 index 0000000..605ac0f --- /dev/null +++ b/src/kgpipe/test/common/test_task_category_catalog.py @@ -0,0 +1,33 @@ +from kgpipe.common.models import TaskCategoryCatalog + + +def test_entity_resolution_children_include_expected_subtasks(): + children = TaskCategoryCatalog.get_children("EntityResolution") + assert "Blocking" in children + assert "Matching" in children + assert "EntityMatching" in children + assert "Clustering" in children + + +def test_subtask_relationships_for_entity_resolution(): + assert TaskCategoryCatalog.is_subtask_of("Blocking", "EntityResolution") + assert TaskCategoryCatalog.is_subtask_of("Matching", "EntityResolution") + assert TaskCategoryCatalog.is_subtask_of("Clustering", "EntityResolution") + assert not TaskCategoryCatalog.is_subtask_of("EntityResolution", "Blocking") + + +def test_ancestors_and_descendants_are_resolved(): + ancestors = TaskCategoryCatalog.get_ancestors("EntityMatching") + descendants = TaskCategoryCatalog.get_descendants("EntityResolution") + + assert ancestors[0] == "EntityResolution" + assert "TaskCategory" in ancestors + assert "Blocking" in descendants + assert "Clustering" in descendants + + +def test_register_custom_category_under_existing_parent(): + TaskCategoryCatalog.register("CandidateGeneration", parent="EntityResolution") + assert TaskCategoryCatalog.has("CandidateGeneration") + assert TaskCategoryCatalog.get_parent("CandidateGeneration") == "EntityResolution" + assert TaskCategoryCatalog.is_subtask_of("CandidateGeneration", "EntityResolution") diff --git a/src/kgpipe/test/common/test_task_model.py b/src/kgpipe/test/common/test_task_model.py new file mode 100644 index 0000000..cbe0e19 --- /dev/null +++ b/src/kgpipe/test/common/test_task_model.py @@ -0,0 +1,136 @@ +from pathlib import Path + +from kgpipe.common.models import Data, DataFormat, KgTask + + +def _write_output_task(inputs: dict[str, Data], outputs: dict[str, Data]) -> None: + _ = inputs["in"] + out_path = outputs["out"].path + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text("generated") + + +def test_kgtask_run_success(tmp_path: Path): + in_file = tmp_path / "input.nt" + out_file = tmp_path / "output.nt" + in_file.write_text("seed") + + task = KgTask( + name="copy_like", + input_spec={"in": DataFormat.RDF_NTRIPLES}, + output_spec={"out": DataFormat.RDF_NTRIPLES}, + function=_write_output_task, + ) + + report = task.run( + inputs=[Data(path=in_file, format=DataFormat.RDF_NTRIPLES)], + outputs=[Data(path=out_file, format=DataFormat.RDF_NTRIPLES)], + ) + + assert report.status == "success" + assert out_file.exists() + assert report.task_name == "copy_like" + assert len(report.inputs) == 1 + assert len(report.outputs) == 1 + + +def test_kgtask_run_failed_when_function_raises(tmp_path: Path): + def failing_task(_: dict[str, Data], __: dict[str, Data]) -> None: + raise RuntimeError("boom") + + in_file = tmp_path / "input.nt" + out_file = tmp_path / "output.nt" + in_file.write_text("seed") + + task = KgTask( + name="fails", + input_spec={"in": DataFormat.RDF_NTRIPLES}, + output_spec={"out": DataFormat.RDF_NTRIPLES}, + function=failing_task, + ) + + report = task.run( + inputs=[Data(path=in_file, format=DataFormat.RDF_NTRIPLES)], + outputs=[Data(path=out_file, format=DataFormat.RDF_NTRIPLES)], + ) + + assert report.status == "failed" + assert report.error is not None + assert "boom" in report.error + + +def test_kgtask_run_skips_when_outputs_exist(tmp_path: Path): + called = {"count": 0} + + def should_not_run(_: dict[str, Data], __: dict[str, Data]) -> None: + called["count"] += 1 + + in_file = tmp_path / "input.nt" + out_file = tmp_path / "output.nt" + in_file.write_text("seed") + out_file.write_text("already-here") + + task = KgTask( + name="skip_if_present", + input_spec={"in": DataFormat.RDF_NTRIPLES}, + output_spec={"out": DataFormat.RDF_NTRIPLES}, + function=should_not_run, + ) + + report = task.run( + inputs=[Data(path=in_file, format=DataFormat.RDF_NTRIPLES)], + outputs=[Data(path=out_file, format=DataFormat.RDF_NTRIPLES)], + ) + + assert report.status == "skipped" + assert called["count"] == 0 + + +def test_kgtask_stable_files_override_forces_run(tmp_path: Path): + called = {"count": 0} + out_file = tmp_path / "output.nt" + + def rewrite_output(_: dict[str, Data], outputs: dict[str, Data]) -> None: + called["count"] += 1 + outputs["out"].path.write_text("fresh") + + in_file = tmp_path / "input.nt" + in_file.write_text("seed") + out_file.write_text("stale") + + task = KgTask( + name="override_output", + input_spec={"in": DataFormat.RDF_NTRIPLES}, + output_spec={"out": DataFormat.RDF_NTRIPLES}, + function=rewrite_output, + ) + + report = task.run( + inputs=[Data(path=in_file, format=DataFormat.RDF_NTRIPLES)], + outputs=[Data(path=out_file, format=DataFormat.RDF_NTRIPLES)], + stable_files_override=True, + ) + + assert report.status == "success" + assert called["count"] == 1 + assert out_file.read_text() == "fresh" + + +def test_kgtask_run_fails_for_missing_required_input(tmp_path: Path): + out_file = tmp_path / "output.nt" + + task = KgTask( + name="needs_input", + input_spec={"in": DataFormat.RDF_NTRIPLES}, + output_spec={"out": DataFormat.RDF_NTRIPLES}, + function=_write_output_task, + ) + + report = task.run( + inputs=[], + outputs=[Data(path=out_file, format=DataFormat.RDF_NTRIPLES)], + ) + + assert report.status == "failed" + assert report.error is not None + assert "Missing required inputs" in report.error From 37f67e49d7979d933a77c47c41a527608b7c1629 Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 2 Apr 2026 19:03:55 +0200 Subject: [PATCH 06/42] exp(examples): keep up examples with new api --- .../examples/src/kgpipe_examples/config.py | 23 +- .../src/kgpipe_examples/eval_examples.py | 88 ++++++++ .../src/kgpipe_examples/pipe_examples.py | 1 + .../src/kgpipe_examples/task_examples.py | 25 ++- .../src/kgpipe_examples/test_examples.py | 208 +++++++++++++++++- 5 files changed, 319 insertions(+), 26 deletions(-) create mode 100644 experiments/examples/src/kgpipe_examples/eval_examples.py diff --git a/experiments/examples/src/kgpipe_examples/config.py b/experiments/examples/src/kgpipe_examples/config.py index c9cc32e..77768ae 100644 --- a/experiments/examples/src/kgpipe_examples/config.py +++ b/experiments/examples/src/kgpipe_examples/config.py @@ -1,19 +1,8 @@ -from enum import Enum -from kgpipe.common.model.data import DynamicFormat, FormatRegistry +from kgpipe.common.model.default_catalog import CustomDataFormats -class ExtendedFormats(Enum): - SPECIAL_IN = DynamicFormat(name="special_in", extension=".special_in", description="Special input format") - SPECIAL1 = DynamicFormat(name="special1", extension=".special1", description="Special format 1") - SPECIAL2 = DynamicFormat(name="special2", extension=".special2", description="Special format 2") - SPECIAL_KG = DynamicFormat(name="special_kg", extension=".special_kg", description="Special output format for knowledge graph") -FORMAT_REGISTRY = FormatRegistry() - -FORMAT_REGISTRY.register_format( - ExtendedFormats.SPECIAL_IN.value.name, ExtendedFormats.SPECIAL_IN.value.extension, ExtendedFormats.SPECIAL_IN.value.description) -FORMAT_REGISTRY.register_format( - ExtendedFormats.SPECIAL1.value.name, ExtendedFormats.SPECIAL1.value.extension, ExtendedFormats.SPECIAL1.value.description) -FORMAT_REGISTRY.register_format( - ExtendedFormats.SPECIAL2.value.name, ExtendedFormats.SPECIAL2.value.extension, ExtendedFormats.SPECIAL2.value.description) -FORMAT_REGISTRY.register_format( - ExtendedFormats.SPECIAL_KG.value.name, ExtendedFormats.SPECIAL_KG.value.extension, ExtendedFormats.SPECIAL_KG.value.description) \ No newline at end of file +class ExtendedFormats(CustomDataFormats): + SPECIAL_IN = "special_in" + SPECIAL1 = "special1" + SPECIAL2 = "special2" + SPECIAL_KG = "special_kg" \ No newline at end of file diff --git a/experiments/examples/src/kgpipe_examples/eval_examples.py b/experiments/examples/src/kgpipe_examples/eval_examples.py new file mode 100644 index 0000000..ae5434f --- /dev/null +++ b/experiments/examples/src/kgpipe_examples/eval_examples.py @@ -0,0 +1,88 @@ +from kgpipe.evaluation.aspects.statistical import ( + StatisticalEvaluator, + StatisticalConfig, + EntityCountMetric +) +from kgpipe.evaluation.aspects.semantic import ( + SemanticEvaluator, + SemanticConfig, + DisjointDomainMetric, + IncorrectRelationDirectionMetric, + IncorrectRelationRangeMetric, + IncorrectRelationDomainMetric, + IncorrectDatatypeMetric, + IncorrectDatatypeFormatMetric, +) +from kgpipe.evaluation.aspects.reference import ( + ReferenceEvaluator, + ReferenceConfig, + SourceTypedEntityCoverageMetric, + ReferenceTripleAlignmentMetric, + ReferenceTripleAlignmentMetricSoftE, + ReferenceTripleAlignmentMetricSoftEV, +) +from kgpipe.common.model.kg import KG +from kgpipe.common.model.default_catalog import BasicDataFormats +from typing import List +from pathlib import Path + +from kgpipe.common.graph import mapper + +TEST_NTRIPLES = """ + . + "itemA" . + "The Hobbit, or There and Back Again" . + . + "9780261102217" . + + . + "itemB" . + "Pride & Prejudice" . + . + "9780199535569" . + + . + "itemC" . + "1984" . + . + "9780452284234" . +""" + +def eval_example(tmp_path: Path): + """Example: Evaluate a KG against a ground truth.""" + + tmp_path = tmp_path / "my_kg.nt" + tmp_path.write_text(TEST_NTRIPLES) + + kg = KG( + id="my_kg", + name="My Knowledge Graph", + path=tmp_path, + format=BasicDataFormats.RDF_NTRIPLES + ) + + statistical_config = StatisticalConfig(name="default") + # semantic_config = SemanticConfig(name="default") + # reference_config = ReferenceConfig( + # name="default" + # REFERENCE_KG_PATH=...) + + statistical_evaluator = StatisticalEvaluator() + # semantic_evaluator = SemanticEvaluator() + # reference_evaluator = ReferenceEvaluator() + + statistical_metrics: List[str] = [EntityCountMetric().name] + # semantic_metrics: List[str] = [DisjointDomainMetric().name, IncorrectRelationDirectionMetric().name, IncorrectRelationRangeMetric().name, IncorrectRelationDomainMetric().name, IncorrectDatatypeMetric().name, IncorrectDatatypeFormatMetric().name] + # reference_metrics: List[str] = [SourceTypedEntityCoverageMetric().name, ReferenceTripleAlignmentMetric().name, ReferenceTripleAlignmentMetricSoftE().name, ReferenceTripleAlignmentMetricSoftEV().name] + + statistical_results = statistical_evaluator.evaluate( + kg, metrics=statistical_metrics, config=statistical_config) + # semantic_results = semantic_evaluator.evaluate( + # kg, metrics=semantic_metrics, config=semantic_config) + # reference_results = reference_evaluator.evaluate( + # kg, metrics=reference_metrics, config=reference_config) + + for metric in statistical_results.metrics: + mapper.metric_run_to_entity(metric) + + return statistical_results #, semantic_results, reference_results \ No newline at end of file diff --git a/experiments/examples/src/kgpipe_examples/pipe_examples.py b/experiments/examples/src/kgpipe_examples/pipe_examples.py index 0a44e3d..79289ea 100644 --- a/experiments/examples/src/kgpipe_examples/pipe_examples.py +++ b/experiments/examples/src/kgpipe_examples/pipe_examples.py @@ -19,6 +19,7 @@ def pipe_example(): tmp_data_dir = tempfile.mkdtemp() input_data = Data(path=os.path.join(tmp_data_dir, "input.special_in"), format=ExtendedFormats.SPECIAL_IN) output_data = Data(path=os.path.join(tmp_data_dir, "output.special_kg"), format=ExtendedFormats.SPECIAL_KG) + input_data.path.touch() tasks = [pipe_task_python, pipe_task_docker, pipe_task_remote] diff --git a/experiments/examples/src/kgpipe_examples/task_examples.py b/experiments/examples/src/kgpipe_examples/task_examples.py index 9a4fa7f..89345d0 100644 --- a/experiments/examples/src/kgpipe_examples/task_examples.py +++ b/experiments/examples/src/kgpipe_examples/task_examples.py @@ -1,17 +1,26 @@ from kgpipe.common import TaskInput, TaskOutput +from kgpipe.common import trace_task_run from kgpipe.common.model.configuration import ConfigurationProfile, ConfigurationDefinition, Parameter, ParameterType from kgpipe_examples.config import ExtendedFormats +from kgpipe.common.model.default_catalog import BasicTaskCategoryCatalog from kgpipe.common.registry import Registry +@trace_task_run @Registry.task( input_spec={"input": ExtendedFormats.SPECIAL_IN}, - output_spec={"output": ExtendedFormats.SPECIAL1} + output_spec={"output": ExtendedFormats.SPECIAL1}, + category=[BasicTaskCategoryCatalog.entity_resolution], + description="A task that processes a special input and produces a special output" ) def pipe_task_python(inputs: TaskInput, outputs: TaskOutput): # touch output file outputs["output"].path.touch() +# def converts_pdfs: pass +# def extracts_text + +@trace_task_run @Registry.task( input_spec={"input": ExtendedFormats.SPECIAL1}, output_spec={"output": ExtendedFormats.SPECIAL2} @@ -20,6 +29,7 @@ def pipe_task_docker(inputs: TaskInput, outputs: TaskOutput): # touch output file outputs["output"].path.touch() +@trace_task_run @Registry.task( input_spec={"input": ExtendedFormats.SPECIAL2}, output_spec={"output": ExtendedFormats.SPECIAL_KG} @@ -29,14 +39,23 @@ def pipe_task_remote(inputs: TaskInput, outputs: TaskOutput): outputs["output"].path.touch() +@trace_task_run @Registry.task( - input_spec={"input": ExtendedFormats.SPECIAL2}, + input_spec={"input": ExtendedFormats.SPECIAL1}, output_spec={"output": ExtendedFormats.SPECIAL_KG}, + category=[BasicTaskCategoryCatalog.entity_resolution], config_spec=ConfigurationDefinition( name="pipe_task_with_config_spec", description="Configuration specification for the pipe_task_with_config task", parameters=[ - Parameter(name="some_parameter", datatype=ParameterType.string, default_value="default", required=False) + Parameter( + name="some_parameter", + native_keys=["some_parameter"], + datatype=ParameterType.string, + default_value="default", + required=False, + allowed_values=[] + ) ] ) ) diff --git a/experiments/examples/src/kgpipe_examples/test_examples.py b/experiments/examples/src/kgpipe_examples/test_examples.py index 08d63f7..c937774 100644 --- a/experiments/examples/src/kgpipe_examples/test_examples.py +++ b/experiments/examples/src/kgpipe_examples/test_examples.py @@ -1,17 +1,213 @@ +from pathlib import Path +from kgpipe.common import Data -def test_python_task_defintion(): + +def test_python_task_execution(tmp_path: Path): + from kgpipe_examples.config import ExtendedFormats from kgpipe_examples.task_examples import pipe_task_python - assert pipe_task_python.name == "pipe_task_python" -def test_docker_task_defintion(): + in_file = tmp_path / "input.special_in" + out_file = tmp_path / "output.special1" + in_file.touch() + + report = pipe_task_python.run( + inputs=[Data(path=in_file, format=ExtendedFormats.SPECIAL_IN)], + outputs=[Data(path=out_file, format=ExtendedFormats.SPECIAL1)], + ) + + assert pipe_task_python.name == "pipe_task_python" + assert report.status == "success" + assert out_file.exists() + + +def test_docker_task_execution(tmp_path: Path): + from kgpipe_examples.config import ExtendedFormats from kgpipe_examples.task_examples import pipe_task_docker + + in_file = tmp_path / "input.special1" + out_file = tmp_path / "output.special2" + in_file.touch() + + report = pipe_task_docker.run( + inputs=[Data(path=in_file, format=ExtendedFormats.SPECIAL1)], + outputs=[Data(path=out_file, format=ExtendedFormats.SPECIAL2)], + ) + assert pipe_task_docker.name == "pipe_task_docker" + assert report.status == "success" + assert out_file.exists() -def test_remote_task_defintion(): + +def test_remote_task_execution(tmp_path: Path): + from kgpipe_examples.config import ExtendedFormats from kgpipe_examples.task_examples import pipe_task_remote + + in_file = tmp_path / "input.special2" + out_file = tmp_path / "output.special_kg" + in_file.touch() + + report = pipe_task_remote.run( + inputs=[Data(path=in_file, format=ExtendedFormats.SPECIAL2)], + outputs=[Data(path=out_file, format=ExtendedFormats.SPECIAL_KG)], + ) + assert pipe_task_remote.name == "pipe_task_remote" + assert report.status == "success" + assert out_file.exists() + + +def test_config_spec_execution(tmp_path: Path): + from kgpipe_examples.config import ExtendedFormats + from kgpipe_examples.task_examples import pipe_task_with_config + from kgpipe.common.model.configuration import ( + ConfigurationProfile, + ParameterBinding, + Parameter, + ParameterType, + ) + + in_file = tmp_path / "input.special1" + out_file = tmp_path / "output.special_kg" + in_file.touch() + + report = pipe_task_with_config.run( + inputs=[Data(path=in_file, format=ExtendedFormats.SPECIAL1)], + outputs=[Data(path=out_file, format=ExtendedFormats.SPECIAL_KG)], + configProfile=ConfigurationProfile( + name="pipe_task_with_config_profile", + definition=pipe_task_with_config.config_spec, + bindings=[ + ParameterBinding( + parameter=Parameter( + name="some_parameter", + native_keys=["some_parameter"], + datatype=ParameterType.string, + default_value="default", + required=False, + allowed_values=[], + ), + value="some", + ) + ], + ), + ) + + assert pipe_task_with_config.name == "pipe_task_with_config" + assert report.status == "success" + assert out_file.exists() + +def test_config_profile_missing_fails(tmp_path: Path): + from kgpipe_examples.config import ExtendedFormats + from kgpipe_examples.task_examples import pipe_task_with_config + + in_file = tmp_path / "input.special1" + out_file = tmp_path / "output.special_kg" + in_file.touch() + + report = pipe_task_with_config.run( + inputs=[Data(path=in_file, format=ExtendedFormats.SPECIAL1)], + outputs=[Data(path=out_file, format=ExtendedFormats.SPECIAL_KG)], + # configProfile intentionally omitted + ) -def test_pipeline_defintion(): + assert report.status == "failed" + assert report.error is not None + assert "requires a 'config' argument" in report.error + + +def test_config_profile_wrong_type_fails(tmp_path: Path): + from kgpipe_examples.config import ExtendedFormats + from kgpipe_examples.task_examples import pipe_task_with_config + + in_file = tmp_path / "input.special1" + out_file = tmp_path / "output.special_kg" + in_file.touch() + + report = pipe_task_with_config.run( + inputs=[Data(path=in_file, format=ExtendedFormats.SPECIAL1)], + outputs=[Data(path=out_file, format=ExtendedFormats.SPECIAL_KG)], + configProfile="not-a-profile", + ) + + assert report.status == "failed" + assert report.error is not None + assert "expects configProfile to be a ConfigurationProfile" in report.error + + +def test_config_profile_spec_mismatch_fails(tmp_path: Path): + from kgpipe_examples.config import ExtendedFormats + from kgpipe_examples.task_examples import pipe_task_with_config + from kgpipe.common.model.configuration import ( + ConfigurationProfile, + ConfigurationDefinition, + ) + + in_file = tmp_path / "input.special1" + out_file = tmp_path / "output.special_kg" + in_file.touch() + + report = pipe_task_with_config.run( + inputs=[Data(path=in_file, format=ExtendedFormats.SPECIAL1)], + outputs=[Data(path=out_file, format=ExtendedFormats.SPECIAL_KG)], + configProfile=ConfigurationProfile( + name="mismatching_profile", + definition=ConfigurationDefinition(name="different_spec_name"), + bindings=[], + ), + ) + + assert report.status == "failed" + assert report.error is not None + assert "does not match task config spec" in report.error + + +def test_config_profile_unknown_parameter_fails(tmp_path: Path): + from kgpipe_examples.config import ExtendedFormats + from kgpipe_examples.task_examples import pipe_task_with_config + from kgpipe.common.model.configuration import ( + ConfigurationProfile, + ParameterBinding, + Parameter, + ParameterType, + ) + + in_file = tmp_path / "input.special1" + out_file = tmp_path / "output.special_kg" + in_file.touch() + + report = pipe_task_with_config.run( + inputs=[Data(path=in_file, format=ExtendedFormats.SPECIAL1)], + outputs=[Data(path=out_file, format=ExtendedFormats.SPECIAL_KG)], + configProfile=ConfigurationProfile( + name="pipe_task_with_config_profile_unknown_param", + definition=pipe_task_with_config.config_spec, + bindings=[ + ParameterBinding( + parameter=Parameter( + name="other_parameter", + native_keys=["other_parameter"], + datatype=ParameterType.string, + default_value="default", + required=False, + allowed_values=[], + ), + value="some", + ) + ], + ), + ) + + assert report.status == "failed" + assert report.error is not None + assert "Unknown config parameter" in report.error + +def test_pipeline_definition_executes(): from kgpipe_examples.pipe_examples import pipe_example - \ No newline at end of file + + # Main objective: execute the pipeline example end-to-end without errors. + pipe_example() + +def test_evaluation_example(tmp_path: Path): + from kgpipe_examples.eval_examples import eval_example + eval_example(tmp_path) \ No newline at end of file From fa406d37019e48138ac59b2db08867fb31ed6ba6 Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 2 Apr 2026 19:04:17 +0200 Subject: [PATCH 07/42] exp(moviekg): added dup rate and entity precision to table --- .../moviekg/src/moviekg/evaluation/helpers.py | 15 +++-- .../src/moviekg/paper/helpers/getter.py | 23 ++++--- .../src/moviekg/paper/helpers/ranking.py | 62 +------------------ .../moviekg/src/moviekg/paper/test_figtab.py | 21 +++---- .../moviekg/src/moviekg/pipelines/helpers.py | 2 +- 5 files changed, 35 insertions(+), 88 deletions(-) diff --git a/experiments/moviekg/src/moviekg/evaluation/helpers.py b/experiments/moviekg/src/moviekg/evaluation/helpers.py index 03ce1d0..4b80b58 100644 --- a/experiments/moviekg/src/moviekg/evaluation/helpers.py +++ b/experiments/moviekg/src/moviekg/evaluation/helpers.py @@ -10,7 +10,7 @@ from kgpipe.evaluation.aspects import reference, semantic, statistical from kgpipe.evaluation.aspects.reference import ReferenceConfig from kgpipe.evaluation.base import MetricResult -from kgcore.model.ontology import OntologyUtil +from kgcore.api.ontology import OntologyUtil from moviekg.datasets.pipe_out import StageOut from moviekg.config import dataset @@ -145,6 +145,8 @@ def add_duration_metrics(stage: StageOut) -> MetricResult: try: duration = stage.report.duration return MetricResult( + metric=None, + kg=KG(id=f"result_{stage.stage_name}", name=f"result_{stage.stage_name}", path=stage.resultKG, format=DataFormat.RDF_NTRIPLES,plan=stage.plan), aspect=EvaluationAspect.STATISTICAL, name="duration", value=duration, @@ -156,6 +158,8 @@ def add_duration_metrics(stage: StageOut) -> MetricResult: except Exception as e: return MetricResult( + metric=None, + kg=KG(id=f"result_{stage.stage_name}", name=f"result_{stage.stage_name}", path=stage.resultKG, format=DataFormat.RDF_NTRIPLES,plan=stage.plan), aspect=EvaluationAspect.STATISTICAL, name="duration", value=0, @@ -178,12 +182,13 @@ def evaluate_stage(stage: StageOut, is_ssp: bool) -> List[MetricResult]: ref_eval = reference.ReferenceEvaluator() sem_eval = semantic.SemanticEvaluator() - stats_aspect_result = stat_eval.evaluate(result_kg) - ref_aspect_result = ref_eval.evaluate(result_kg, config=get_reference_config(stage, is_ssp)) - sem_aspect_result = sem_eval.evaluate(result_kg) + # stats_aspect_result = stat_eval.evaluate(result_kg) + ref_aspect_result = ref_eval.evaluate(result_kg, config=get_reference_config(stage, is_ssp), metrics=["SourceTypedEntityCoverageMetric"]) + # sem_aspect_result = sem_eval.evaluate(result_kg) metrics = [] - metrics = stats_aspect_result.metrics + ref_aspect_result.metrics + sem_aspect_result.metrics + # metrics = stats_aspect_result.metrics + ref_aspect_result.metrics + sem_aspect_result.metrics + metrics = ref_aspect_result.metrics # metrics = sem_aspect_result.metrics metrics.append(add_duration_metrics(stage)) # metrics = ref_aspect_result.metrics diff --git a/experiments/moviekg/src/moviekg/paper/helpers/getter.py b/experiments/moviekg/src/moviekg/paper/helpers/getter.py index ab7fce4..c5889a4 100644 --- a/experiments/moviekg/src/moviekg/paper/helpers/getter.py +++ b/experiments/moviekg/src/moviekg/paper/helpers/getter.py @@ -163,13 +163,23 @@ def ref_source_entity_r(df: pd.DataFrame): res[row.pipeline][row.stage] = recall return res +def ref_source_typed_entity_fn(df: pd.DataFrame): + df = df[df["metric"] == "SourceTypedEntityCoverageMetric"] + res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) + for row in df.itertuples(): + details = json.loads(row.details) + # print(details) + fn = details.get("fn", -1) + res[row.pipeline][row.stage] = fn + return res + def ref_source_typed_entity_p(df: pd.DataFrame): df = df[df["metric"] == "SourceTypedEntityCoverageMetric"] res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) for row in df.itertuples(): details = json.loads(row.details) # print(details) - precision = details.get("fn", -1) + precision = details.get("precision", -1) res[row.pipeline][row.stage] = precision return res @@ -372,6 +382,7 @@ def ref_json_entity_linking_r(df: pd.DataFrame): ref_source_entity_r.__name__: "VSEC-R", ref_source_typed_entity_p.__name__: "VSEC-P-TE", ref_source_typed_entity_r.__name__: "VSEC-R-TE", + ref_source_typed_entity_fn.__name__: "VSEC-FN-TE", ref_entity_matching_f1.__name__: "ER-EM", ref_entity_matching_p.__name__: "ER-EM-P", ref_entity_matching_r.__name__: "ER-EM-R", @@ -520,13 +531,7 @@ def apply_selected_updates(psmd: pipeline_stage_metric_dict) -> pipeline_stage_m agg_metric_over_stages(psmd, "ref_selected_task_metric", "_avg", agg_avg) agg_metric_over_stages(psmd, "sta_duration", "_sum", agg_sum) agg_metric_over_stages(psmd, "ref_source_entity_f1", "_avg", agg_avg) - agg_metric_over_stages(psmd, "ref_source_entity_p", "_avg", agg_avg) - agg_metric_over_stages(psmd, "ref_source_entity_r", "_avg", agg_avg) - agg_metric_over_stages(psmd, "ref_source_typed_entity_p", "_avg", agg_avg) - agg_metric_over_stages(psmd, "ref_source_typed_entity_r", "_avg", agg_avg) - # agg_metric_over_stages(psmd, "ref_kg_f1", "_avg", agg_avg) - # agg_metric_over_stages(psmd, "ref_kg_p", "_avg", agg_avg) - # agg_metric_over_stages(psmd, "ref_kg_r", "_avg", agg_avg) + agg_metric_over_stages(psmd, "ref_kg_f1", "_avg", agg_avg) return psmd def test_getter(): @@ -549,4 +554,4 @@ def test_getter(): print(pipeline) print(stage) print(json.dumps(metric_dict, indent=4)) - print("--------------------------------") + print("--------------------------------") \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/paper/helpers/ranking.py b/experiments/moviekg/src/moviekg/paper/helpers/ranking.py index e181e15..ba9102a 100644 --- a/experiments/moviekg/src/moviekg/paper/helpers/ranking.py +++ b/experiments/moviekg/src/moviekg/paper/helpers/ranking.py @@ -8,7 +8,7 @@ TABLE_DISPLAY_NAMES, normalize_metric, normalize_min_best, normalize_max_best, sta_fact_count, sta_denisity, sta_duration, #memory_peak is not considered - ref_kg_p, ref_kg_r, ref_source_entity_f1, ref_source_entity_r, ref_source_entity_p, + ref_kg_p, ref_source_entity_f1, sem_disjoint_domain, sem_incorrect_relation_direction, sem_incorrect_relation_range, sem_incorrect_relation_domain, sem_incorrect_datatype, sem_incorrect_datatype_format ) @@ -99,66 +99,6 @@ def _rank_and_save2csv(weights: dict, outfile_stem: str, psmd: pipeline_stage_me out = df[["pipeline", "combined"]].sort_values(by="combined", ascending=False) out.to_csv(OUTPUT_ROOT / f"paper/{outfile_stem}.csv", sep="\t") -def _rank_and_save3csv(outfile_stem: str, psmd: pipeline_stage_metric_dict, round_digits: int = 3) -> pd.DataFrame: - - # psmd = normalize_metric(psmd, sta_fact_count.__name__, ["stage_3"], normalize_max_best) - # psmd = normalize_metric(psmd, sta_denisity.__name__, ["stage_3"], normalize_max_best) - # psmd = normalize_metric(psmd, sta_fact_count.__name__, ["stage_3"], normalize_max_best) - # sta_metric_names = [sta_denisity.__name__+"_norm", sta_fact_count.__name__+"_norm"] - # sta_agg = agg_metrics(psmd, sta_metric_names) - - sem_metric_names = [ - sem_disjoint_domain.__name__, sem_incorrect_relation_direction.__name__, - sem_incorrect_relation_range.__name__, sem_incorrect_relation_domain.__name__, - sem_incorrect_datatype.__name__, sem_incorrect_datatype_format.__name__] - sem_agg = agg_metrics(psmd, sem_metric_names) - - acc_metric_names = [ref_kg_p.__name__] # only final stage (3) - acc_agg = agg_metrics(psmd, acc_metric_names) - - cov_metric_names = [ref_source_typed_entity_r.__name__+"_avg"] # avg of all stages - cov_agg = agg_metrics(psmd, cov_metric_names) - - # psmd = normalize_metric(psmd, sta_duration.__name__+"_sum", ["stage_3"], normalize_min_best) - # eff_metric_names = [sta_duration.__name__+"_sum_norm"] - # eff_agg = agg_metrics(psmd, eff_metric_names) - - import json - json.dump(psmd, open(OUTPUT_ROOT / f"paper/{outfile_stem}_psmd.json", "w"), indent=4) - - df_rows = [] - - for pipeline, value in sem_agg.items(): - df_rows.append( - { - "pipeline": pipeline, - "semantic": round(value, round_digits), - "correctness": round(acc_agg[pipeline], round_digits), - "coverage": round(cov_agg[pipeline], round_digits), - # "size": round(sta_agg[pipeline], round_digits), - # "efficiency": round(eff_agg[pipeline], round_digits) - } - ) - - - df = pd.DataFrame(df_rows) - - return df - - # cols = ["semantic", "correctness", "coverage"] - # # Ensure we only use known columns; fill missing weights with 0.0 - # w = pd.Series(weights).reindex(cols, fill_value=0.0) - - # # Compute combined score - # df = df[["pipeline"] + cols].copy() - # df["combined"] = (df[cols] * w).sum(axis=1).round(round_digits) - - # print(df.to_string()) - - # # Sort & save (keep default index=True to match original behavior) - # out = df[["pipeline", "combined"]].sort_values(by="combined", ascending=False) - # out.to_csv(OUTPUT_ROOT / f"paper/{outfile_stem}.csv", sep="\t") - # TODO cleanup # def _rank_and_save(weights: dict, outfile_stem: str, df: pd.DataFrame, round_digits: int = 3) -> None: # """ diff --git a/experiments/moviekg/src/moviekg/paper/test_figtab.py b/experiments/moviekg/src/moviekg/paper/test_figtab.py index f2dca06..6eb685c 100644 --- a/experiments/moviekg/src/moviekg/paper/test_figtab.py +++ b/experiments/moviekg/src/moviekg/paper/test_figtab.py @@ -456,11 +456,11 @@ def test_table_6(): metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) from moviekg.paper.helpers.getter import ( - get_pipeline_stage_metric_dict, ref_kg_f1, ref_kg_p, ref_kg_r, ref_source_entity_f1, ref_source_entity_p, ref_source_entity_r, ref_source_typed_entity_r, ref_source_typed_entity_p + get_pipeline_stage_metric_dict, ref_kg_f1, ref_kg_p, ref_kg_r, ref_source_entity_f1, ref_source_entity_p, ref_source_entity_r ) metrics = [ - ref_kg_f1.__name__, ref_kg_p.__name__, ref_kg_r.__name__, ref_source_entity_f1.__name__, ref_source_entity_p.__name__, ref_source_entity_r.__name__, ref_source_typed_entity_r.__name__, ref_source_typed_entity_p.__name__ + ref_kg_f1.__name__, ref_kg_p.__name__, ref_kg_r.__name__, ref_source_entity_f1.__name__, ref_source_entity_p.__name__, ref_source_entity_r.__name__ ] psmd = get_pipeline_stage_metric_dict(metric_df, metrics) @@ -469,7 +469,7 @@ def test_table_6(): rows = [] - round_to = 3 + round_to = 2 for pipeline, stage_dict in psmd.items(): if pipeline in ["reference", "seed"]: @@ -478,22 +478,18 @@ def test_table_6(): kg_r = [0, 0, 0] se_p = [0, 0, 0] se_r = [0, 0, 0] - ste_p = [0, 0, 0] - ste_r = [0, 0, 0] + for stage, metric_dict in stage_dict.items(): kg_p[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_kg_p.__name__, -1), round_to) kg_r[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_kg_r.__name__, -1), round_to) se_p[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_source_entity_p.__name__, -1), round_to) se_r[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_source_entity_r.__name__, -1), round_to) - ste_p[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_source_typed_entity_p.__name__, -1), round_to) - ste_r[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_source_typed_entity_r.__name__, -1), round_to) rows.append({ "pipeline": pipeline, "kg_p@1": kg_p[0], "kg_r@1": kg_r[0], "kg_p@2": kg_p[1], "kg_r@2": kg_r[1], "kg_p@3": kg_p[2], "kg_r@3": kg_r[2], - "se_p@1": se_p[0], "se_r@1": se_r[0], "se_p@2": se_p[1], "se_r@2": se_r[1], "se_p@3": se_p[2], "se_r@3": se_r[2], - "ste_p@1": ste_p[0], "ste_r@1": ste_r[0], "ste_p@2": ste_p[1], "ste_r@2": ste_r[1], "ste_p@3": ste_p[2], "ste_r@3": ste_r[2]}) + "se_p@1": se_p[0], "se_r@1": se_r[0], "se_p@2": se_p[1], "se_r@2": se_r[1], "se_p@3": se_p[2], "se_r@3": se_r[2]}) df = pd.DataFrame(rows) output_path = OUTPUT_ROOT / "paper/test_tab_6_reference_alignment.csv" @@ -731,7 +727,7 @@ def test_new_quality_table(): sta_entity_count, sta_fact_count, sta_type_count, sta_relation_count, sta_shallow_entity_count, sta_denisity, sta_duration, ref_kg_f1, ref_kg_p, ref_kg_r, ref_source_entity_f1, ref_source_entity_p, ref_source_entity_r, - ref_source_typed_entity_r, ref_source_typed_entity_p, + ref_source_typed_entity_r, ref_source_typed_entity_p, ref_source_typed_entity_fn, sem_disjoint_domain, sem_incorrect_relation_direction, sem_incorrect_relation_cardinality, sem_incorrect_relation_range, sem_incorrect_relation_domain, sem_incorrect_datatype, sem_incorrect_datatype_format, ) @@ -740,7 +736,7 @@ def test_new_quality_table(): ref_kg_f1.__name__, ref_kg_p.__name__, ref_kg_r.__name__, ref_source_entity_f1.__name__, ref_source_entity_p.__name__, ref_source_entity_r.__name__, - ref_source_typed_entity_r.__name__, ref_source_typed_entity_p.__name__, + ref_source_typed_entity_r.__name__, ref_source_typed_entity_p.__name__, ref_source_typed_entity_fn.__name__, sem_disjoint_domain.__name__, sem_incorrect_relation_direction.__name__, sem_incorrect_relation_cardinality.__name__, sem_incorrect_relation_range.__name__, sem_incorrect_relation_domain.__name__, sem_incorrect_datatype.__name__, sem_incorrect_datatype_format.__name__, ] @@ -764,6 +760,7 @@ def test_new_quality_table(): se_r= round(metric_dict.get(ref_source_entity_r.__name__, -1), round_to) ste_p = round(metric_dict.get(ref_source_typed_entity_p.__name__, -1), round_to) ste_r = round(metric_dict.get(ref_source_typed_entity_r.__name__, -1), round_to) + ste_fn = round(metric_dict.get(ref_source_typed_entity_fn.__name__, -1), round_to) o_dt = round(metric_dict.get(sem_disjoint_domain.__name__, -1), round_to) o_d = round(metric_dict.get(sem_incorrect_relation_domain.__name__, -1), round_to) o_r = round(metric_dict.get(sem_incorrect_relation_range.__name__, -1), round_to) @@ -774,7 +771,7 @@ def test_new_quality_table(): rows.append({ "pipeline": pipeline, "stage": stage, "EC": ec, - "kg_p": kg_p, "kg_r": kg_r, "se_p": se_p, "se_r": se_r, "ste_p": ste_p, "ste_r": ste_r, + "kg_p": kg_p, "kg_r": kg_r, "se_p": se_p, "se_r": se_r, "ste_p": ste_p, "ste_r": ste_r, "ste_fn": ste_fn, "O_DT": o_dt, "O_D": o_d, "O_R": o_r, "O_RD": o_rd, "O_LT": o_lt, "O_LF": o_lf }) diff --git a/experiments/moviekg/src/moviekg/pipelines/helpers.py b/experiments/moviekg/src/moviekg/pipelines/helpers.py index cfc4e92..fab8ee3 100644 --- a/experiments/moviekg/src/moviekg/pipelines/helpers.py +++ b/experiments/moviekg/src/moviekg/pipelines/helpers.py @@ -70,7 +70,7 @@ def run_helper( tmp_dir = stage_dir / "tmp" tmp_dir.mkdir(parents=True, exist_ok=True) - pipeline = build_from_conf(pipeline_name, pipeline_conf, target_data, tmp_dir.as_posix()) + pipeline = build_from_conf(pipeline_conf, target_data, tmp_dir.as_posix()) stage_dir.mkdir(parents=True, exist_ok=True) From 442ef68f7fd23e9dc4b6463cc72602f002f42ac2 Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 2 Apr 2026 18:01:07 +0200 Subject: [PATCH 08/42] feat(eval): init refactor --- src/kgpipe_eval/__init__.py | 2 + src/kgpipe_eval/api.py | 41 ++++++ src/kgpipe_eval/metrics/__init__.py | 108 ++++++++++++++ src/kgpipe_eval/metrics/alignment_utils.py | 18 +++ src/kgpipe_eval/metrics/annotation_utils.py | 9 ++ src/kgpipe_eval/metrics/duplicates.py | 19 +++ src/kgpipe_eval/metrics/entailment_utils.py | 0 src/kgpipe_eval/metrics/kg_utils.py | 135 ++++++++++++++++++ src/kgpipe_eval/metrics/reference_entities.py | 5 + src/kgpipe_eval/metrics/reference_triples.py | 0 src/kgpipe_eval/metrics/statistics.py | 47 ++++++ 11 files changed, 384 insertions(+) create mode 100644 src/kgpipe_eval/__init__.py create mode 100644 src/kgpipe_eval/api.py create mode 100644 src/kgpipe_eval/metrics/__init__.py create mode 100644 src/kgpipe_eval/metrics/alignment_utils.py create mode 100644 src/kgpipe_eval/metrics/annotation_utils.py create mode 100644 src/kgpipe_eval/metrics/duplicates.py create mode 100644 src/kgpipe_eval/metrics/entailment_utils.py create mode 100644 src/kgpipe_eval/metrics/kg_utils.py create mode 100644 src/kgpipe_eval/metrics/reference_entities.py create mode 100644 src/kgpipe_eval/metrics/reference_triples.py create mode 100644 src/kgpipe_eval/metrics/statistics.py diff --git a/src/kgpipe_eval/__init__.py b/src/kgpipe_eval/__init__.py new file mode 100644 index 0000000..04e3f84 --- /dev/null +++ b/src/kgpipe_eval/__init__.py @@ -0,0 +1,2 @@ +# Refactor of kgpipe.evaluation to be a standalone package + diff --git a/src/kgpipe_eval/api.py b/src/kgpipe_eval/api.py new file mode 100644 index 0000000..8b37e9e --- /dev/null +++ b/src/kgpipe_eval/api.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass +from functools import lru_cache +from abc import ABC, abstractmethod +from typing import Callable +from kgpipe.common.model.kg import KgKg + + +# MetricConfig (rich, typed, input) +# ↓ +# computation +# ↓ +# MetricResult +# ├── measurements (results) +# └── metadata (flattened config + context) + +@dataclass(frozen=True) +class MetricConfig: + pass + +@dataclass(frozen=True) +class Measurement: + name: str + value: int | float | str | bool + unit: str | None = None + +@dataclass(frozen=True) +class MetricResult: + metric: "Metric" + measurements: list[Measurement] + summary: str | None = None + # TODO metadata/properties: dict[str, int | float | str | bool] = field(default_factory=dict) + +@dataclass(frozen=True) +class Metric: + key: str + description: str + compute: Callable[[KgKg, MetricConfig], MetricResult] + + +# --- + diff --git a/src/kgpipe_eval/metrics/__init__.py b/src/kgpipe_eval/metrics/__init__.py new file mode 100644 index 0000000..a1ccd07 --- /dev/null +++ b/src/kgpipe_eval/metrics/__init__.py @@ -0,0 +1,108 @@ + + +# @dataclass(frozen=True) +# class BinaryClassificationStats: +# tp: int +# fp: int +# tn: int +# fn: int + +# def recall(self) -> float: +# d = self.tp + self.fn +# return self.tp / d if d else 0.0 + +# def precision(self) -> float: +# d = self.tp + self.fp +# return self.tp / d if d else 0.0 + +# def f1(self) -> float: +# p = self.precision() +# r = self.recall() +# return 2 * p * r / (p + r) if (p + r) else 0.0 + +# def accuracy(self) -> float: +# d = self.tp + self.fp + self.tn + self.fn +# return (self.tp + self.tn) / d if d else 0.0 + +# def reference_binary_classification(kg: KgKg, config: MetricConfig) -> MetricResult: +# stats = BinaryClassificationStats(tp=10, fp=5, tn=15, fn=3) +# return MetricResult( +# metric_key="reference_binary_classification", +# summary="Reference comparison computed", +# measurements=[ +# Measurement("tp", stats.tp), +# Measurement("fp", stats.fp), +# Measurement("tn", stats.tn), +# Measurement("fn", stats.fn), +# Measurement("precision", stats.precision(), "ratio"), +# Measurement("recall", stats.recall(), "ratio"), +# Measurement("f1", stats.f1(), "ratio"), +# Measurement("accuracy", stats.accuracy(), "ratio"), +# ], +# ) + +# def graph_size(kg: KgKg, config: MetricConfig) -> MetricResult: +# size = 1532 +# return MetricResult( +# metric_key="graph_size", +# measurements=[ +# Measurement("triple_count", size, "triples") +# ], +# summary=f"Graph contains {size} triples", +# ) + +# def entity_duplication_rate(kg: KgKg, config: MetricConfig) -> MetricResult: +# duplicates = 7 +# total = 100 +# rate = duplicates / total if total else 0.0 +# return MetricResult( +# metric_key="entity_duplication_rate", +# measurements=[ +# Measurement("duplication_rate", rate, "ratio"), +# Measurement("duplicate_entities", duplicates, "entities"), +# Measurement("total_entities", total, "entities"), +# ], +# summary=f"Entity duplication rate: {rate:.2%}", +# ) +# --- + +# class BinaryClassifier(): +# tp: int +# fp: int +# tn: int +# fn: int + +# def recall(self) -> float: +# return self.tp / (self.tp + self.fn) + +# def precision(self) -> float: +# return self.tp / (self.tp + self.fp) + +# def f1(self) -> float: +# return 2 * self.precision() * self.recall() / (self.precision() + self.recall()) + +# def accuracy(self) -> float: +# return (self.tp + self.tn) / (self.tp + self.tn + self.fp + self.fn) + +# @lru_cache +# def compute_binary_classifier(kg: KgKg) -> BinaryClassifier: +# return BinaryClassifier(tp=10, fp=5, tn=15, fn=3) + + +# # Option 1 the metrics are recall, precision, f1, accuracy +# def reference_recall(kg: KgKg, reference: KgKg) -> KgMetricResult: +# binary_classifier = compute_binary_classifier(kg, reference) +# return KgMetricResult(summary=f"Reference recall: {binary_classifier.recall()}") + +# def reference_precision(kg: KgKg, reference: KgKg) -> KgMetricResult: +# binary_classifier = compute_binary_classifier(kg, reference) +# return KgMetricResult(summary=f"Reference precision: {binary_classifier.precision()}") + +# def reference_f1(kg: KgKg, reference: KgKg) -> KgMetricResult: +# binary_classifier = compute_binary_classifier(kg, reference) +# return KgMetricResult(summary=f"Reference F1: {binary_classifier.f1()}") + +# #Option 2 the metrics are Binary Classification which allows for more detailed analysis +# def reference_binary_classification(kg: KgKg, reference: KgKg) -> KgMetricResult: +# binary_classifier = compute_binary_classifier(kg, reference) +# return KgMetricResult(summary=f"Reference binary classification: {binary_classifier.tp}, {binary_classifier.fp}, {binary_classifier.tn}, {binary_classifier.fn}") \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/alignment_utils.py b/src/kgpipe_eval/metrics/alignment_utils.py new file mode 100644 index 0000000..5c19403 --- /dev/null +++ b/src/kgpipe_eval/metrics/alignment_utils.py @@ -0,0 +1,18 @@ +from kgpipe.common import KG +from typing import Literal + +CONFIG=None +# layz config dict +def get_config() -> dict: + global CONFIG + if CONFIG is None: + # TODO + pass + return CONFIG + +def get_aligned_entities(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Entity]: + return kg.entities.intersection(reference_kg.entities) + +def get_aligned_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Triple]: + return kg.triples.intersection(reference_kg.triples) + diff --git a/src/kgpipe_eval/metrics/annotation_utils.py b/src/kgpipe_eval/metrics/annotation_utils.py new file mode 100644 index 0000000..765ff78 --- /dev/null +++ b/src/kgpipe_eval/metrics/annotation_utils.py @@ -0,0 +1,9 @@ + + +# Labels + +def get_labeled_entities(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Entity]: + return kg.entities.intersection(reference_kg.entities) + +def get_labeled_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Triple]: + return kg.triples.intersection(reference_kg.triples) \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/duplicates.py b/src/kgpipe_eval/metrics/duplicates.py new file mode 100644 index 0000000..3bb868c --- /dev/null +++ b/src/kgpipe_eval/metrics/duplicates.py @@ -0,0 +1,19 @@ + +def eval_duplicates(): + pass + + +# find all duplicate entities in the KG +# using +# - reference KG +# - fuzzy matching +# - exact matching +# - semantic matching +# - clustering +# - ... +# return a list of duplicate entities +# return a list of duplicate entities with the matching score +# return a list of duplicate entities with the matching score and the matching type +# return a list of duplicate entities with the matching score and the matching type and the matching details +# return a list of duplicate entities with the matching score and the matching type and the matching details and the matching details +# return a list of duplicate entities with the matching score and the matching type and the matching details and the matching details and the matching details \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/entailment_utils.py b/src/kgpipe_eval/metrics/entailment_utils.py new file mode 100644 index 0000000..e69de29 diff --git a/src/kgpipe_eval/metrics/kg_utils.py b/src/kgpipe_eval/metrics/kg_utils.py new file mode 100644 index 0000000..bfd1232 --- /dev/null +++ b/src/kgpipe_eval/metrics/kg_utils.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Protocol, Union, runtime_checkable, Optional, Tuple, Literal + +from rdflib import RDF, Graph +from rdflib.term import Identifier +from rdflib.term import Literal +from rdflib.term import URIRef + +from kgpipe.common import KG + + +KgLike = Union[KG, Graph, str, Path] + +Term = Union[Identifier, str, URIRef, Literal] + +Triple = tuple[Term, Term, Term] + +TriplePattern = Tuple[ + Optional[Term], Optional[Term], Optional[Term] +] + +@runtime_checkable +class TripleGraph(Protocol): + """ + TripleGraph is a protocol that defines the interface for a graph that can be used to evaluate metrics. + It is used to abstract the underlying graph implementation and allow for different graph implementations to be used. + + This is intentionally small: metrics should depend on *these* operations, + not on a specific in-memory representation (RDFLib Graph today; Spark later). + """ + + def triples(self, triple_pattern: TriplePattern) -> Iterable[Triple]: + pass + + def close(self) -> None: + pass + + def cache(self) -> None: + pass + +# def iter_triples(self) -> Iterable[Triple]: +# """Iterate (s, p, o) triples.""" + +# @property +# def triples(self) -> frozenset[Triple]: +# """Materialized triple set (may be expensive).""" + +# @property +# def entities(self) -> frozenset[Term]: +# """All subjects/objects that are IRIs or blank nodes (no literals).""" + +# @property +# def relations(self) -> frozenset[Term]: +# """All predicates.""" + +# @property +# def classes(self) -> frozenset[Term]: +# """All classes used in rdf:type assertions.""" + +# @property +# def class_occurrences(self) -> Mapping[Term, int]: +# """Class → number of rdf:type occurrences.""" + +@dataclass(frozen=True) +class SparkTripleGraph(TripleGraph): + """ + KG backend that exposes evaluation-friendly views derived from a Spark DataFrame. + """ + # df: SparkDataFrame + + def triples(self, triple_pattern: TriplePattern) -> Iterable[Triple]: + # return self.df.filter(triple_pattern).collect() + pass + + def close(self) -> None: + pass + + def cache(self) -> None: + pass + +@dataclass(frozen=True) +class RdfLibTripleGraph(TripleGraph): + """ + KG backend that exposes evaluation-friendly views derived from an RDFLib `Graph`. + + Accepts: + - `kgpipe.common.KG` (uses `get_graph()`) + - an RDFLib `Graph` + - a path/str (parsed by RDFLib) + """ + kg: KgLike + + def _graph(self) -> Graph: + if isinstance(self.kg, Graph): + return self.kg + if isinstance(self.kg, KG): + return self.kg.get_graph() + # Assume filesystem path + return Graph().parse(str(self.kg)) + + def triples(self, triple_pattern: TriplePattern) -> Iterable[Triple]: + g = self._graph() + # RDFLib yields (s, p, o) as Identifiers + return g.triples(triple_pattern) + +class KgManager: + """ + KgManager is a class that manages the loading and unloading of KGs. + It is used to abstract the underlying graph implementation and allow for different graph implementations to be used. + """ + + @staticmethod + def load_kg(kg: KG, backend: Literal["rdflib", "spark"] = "rdflib") -> TripleGraph: + if backend == "rdflib": + return RdfLibTripleGraph(kg=kg) + else: + raise ValueError(f"Unsupported backend: {backend}") + + @staticmethod + def load_kg_from_path(path: Path, backend: Literal["rdflib", "spark"] = "rdflib") -> TripleGraph: + if backend == "rdflib": + return RdfLibTripleGraph(kg=path) + else: + raise ValueError(f"Unsupported backend: {backend}") + + @staticmethod + def cache_kg(kg: TripleGraph) -> None: + kg.cache() + + @staticmethod + def unload_kg(kg: TripleGraph) -> None: + kg.close() diff --git a/src/kgpipe_eval/metrics/reference_entities.py b/src/kgpipe_eval/metrics/reference_entities.py new file mode 100644 index 0000000..0b14d66 --- /dev/null +++ b/src/kgpipe_eval/metrics/reference_entities.py @@ -0,0 +1,5 @@ +from kgpipe_eval.metrics.alignment_utils import get_aligned_entities +from kgpipe.common import KG +from typing import Literal + +# TODO \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/reference_triples.py b/src/kgpipe_eval/metrics/reference_triples.py new file mode 100644 index 0000000..e69de29 diff --git a/src/kgpipe_eval/metrics/statistics.py b/src/kgpipe_eval/metrics/statistics.py new file mode 100644 index 0000000..e615e6c --- /dev/null +++ b/src/kgpipe_eval/metrics/statistics.py @@ -0,0 +1,47 @@ +from kgpipe.common import KG +from kgpipe_eval.metrics.kg_utils import TripleGraph, KgManager +from kgpipe_eval.api import Metric, MetricResult, MetricConfig +from functools import lru_cache + +from pydantic import BaseModel +from typing import Mapping +from collections import defaultdict + +from rdflib import RDF, RDFS +from rdflib.term import URIRef, Literal + +class CountMeasures(BaseModel): + entity_count: int + triple_count: int + property_count: int + class_count: int + property_occurrence: Mapping[str, int] + class_occurrence: Mapping[str, int] + +@lru_cache(maxsize=1000) +def count_measures(kg: TripleGraph) -> CountMeasures: + + entity_count = 0 # TODO requires distinct entities + triple_count = 0 + + class_occurrence = defaultdict(int) + property_occurrence = defaultdict(int) + + for s, p, o in kg.triples((None, None, None)): + triple_count += 1 + if p == RDF.type: + class_occurrence[str(o)] += 1 + property_occurrence[str(p)] += 1 + + return CountMeasures( + entity_count=entity_count, + property_count=len(property_occurrence.keys()), + triple_count=triple_count, + class_count=len(class_occurrence.keys()), + class_occurrence=class_occurrence, + property_occurrence=property_occurrence, + ) + +class CountMetric(Metric): + def compute(self, kg: TripleGraph) -> MetricResult: + return MetricResult(value=count_measures(kg)) \ No newline at end of file From 34b492737f91987e7a3711e742924a9d45d1e975 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Mon, 6 Apr 2026 11:04:02 +0200 Subject: [PATCH 09/42] feat(eval): stash --- src/kgpipe_eval/metrics/alignment_utils.py | 18 ----- src/kgpipe_eval/metrics/annotation_utils.py | 9 --- .../metrics/consistency_violations.py | 44 ++++++++++++ src/kgpipe_eval/metrics/duplicates.py | 43 +++++++++++- src/kgpipe_eval/metrics/entity_alignment.py | 45 ++++++++++++ src/kgpipe_eval/metrics/llm_annotation.py | 4 ++ src/kgpipe_eval/metrics/reference_entities.py | 5 -- src/kgpipe_eval/metrics/statistics.py | 20 ++++-- src/kgpipe_eval/metrics/triple_alignment.py | 23 +++++++ .../entailment_utils.py => test/__init__.py} | 0 src/kgpipe_eval/test/examples.py | 25 +++++++ .../test_alignment_eval.py} | 0 src/kgpipe_eval/test/test_llm_eval.py | 6 ++ src/kgpipe_eval/test/test_source_eval.py | 0 src/kgpipe_eval/test/test_statistics_eval.py | 7 ++ src/kgpipe_eval/test/utils.py | 11 +++ src/kgpipe_eval/utils/__init__.py | 0 src/kgpipe_eval/utils/alignment_utils.py | 68 +++++++++++++++++++ src/kgpipe_eval/utils/annotation_utils.py | 51 ++++++++++++++ src/kgpipe_eval/utils/entailment_utils.py | 7 ++ .../{metrics => utils}/kg_utils.py | 28 ++++++-- src/kgpipe_eval/utils/measurement_utils.py | 36 ++++++++++ src/kgpipe_eval/utils/verbalize_utils.py | 16 +++++ 23 files changed, 423 insertions(+), 43 deletions(-) delete mode 100644 src/kgpipe_eval/metrics/alignment_utils.py delete mode 100644 src/kgpipe_eval/metrics/annotation_utils.py create mode 100644 src/kgpipe_eval/metrics/consistency_violations.py create mode 100644 src/kgpipe_eval/metrics/entity_alignment.py create mode 100644 src/kgpipe_eval/metrics/llm_annotation.py delete mode 100644 src/kgpipe_eval/metrics/reference_entities.py create mode 100644 src/kgpipe_eval/metrics/triple_alignment.py rename src/kgpipe_eval/{metrics/entailment_utils.py => test/__init__.py} (100%) create mode 100644 src/kgpipe_eval/test/examples.py rename src/kgpipe_eval/{metrics/reference_triples.py => test/test_alignment_eval.py} (100%) create mode 100644 src/kgpipe_eval/test/test_llm_eval.py create mode 100644 src/kgpipe_eval/test/test_source_eval.py create mode 100644 src/kgpipe_eval/test/test_statistics_eval.py create mode 100644 src/kgpipe_eval/test/utils.py create mode 100644 src/kgpipe_eval/utils/__init__.py create mode 100644 src/kgpipe_eval/utils/alignment_utils.py create mode 100644 src/kgpipe_eval/utils/annotation_utils.py create mode 100644 src/kgpipe_eval/utils/entailment_utils.py rename src/kgpipe_eval/{metrics => utils}/kg_utils.py (85%) create mode 100644 src/kgpipe_eval/utils/measurement_utils.py create mode 100644 src/kgpipe_eval/utils/verbalize_utils.py diff --git a/src/kgpipe_eval/metrics/alignment_utils.py b/src/kgpipe_eval/metrics/alignment_utils.py deleted file mode 100644 index 5c19403..0000000 --- a/src/kgpipe_eval/metrics/alignment_utils.py +++ /dev/null @@ -1,18 +0,0 @@ -from kgpipe.common import KG -from typing import Literal - -CONFIG=None -# layz config dict -def get_config() -> dict: - global CONFIG - if CONFIG is None: - # TODO - pass - return CONFIG - -def get_aligned_entities(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Entity]: - return kg.entities.intersection(reference_kg.entities) - -def get_aligned_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Triple]: - return kg.triples.intersection(reference_kg.triples) - diff --git a/src/kgpipe_eval/metrics/annotation_utils.py b/src/kgpipe_eval/metrics/annotation_utils.py deleted file mode 100644 index 765ff78..0000000 --- a/src/kgpipe_eval/metrics/annotation_utils.py +++ /dev/null @@ -1,9 +0,0 @@ - - -# Labels - -def get_labeled_entities(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Entity]: - return kg.entities.intersection(reference_kg.entities) - -def get_labeled_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Triple]: - return kg.triples.intersection(reference_kg.triples) \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/consistency_violations.py b/src/kgpipe_eval/metrics/consistency_violations.py new file mode 100644 index 0000000..7cd1141 --- /dev/null +++ b/src/kgpipe_eval/metrics/consistency_violations.py @@ -0,0 +1,44 @@ +from kgpipe_eval.api import Metric + +from pydantic import BaseModel +from kgpipe.common import KG + +class DisjointDomainConfig(BaseModel): + pass + +class DomainConfig(BaseModel): + pass + +class RangeConfig(BaseModel): + pass + +class RelationDirectionConfig(BaseModel): + pass + +class DisjointDomainMetric(Metric): + def compute(self, kg: KG, ref_kg: KG, config: DisjointDomainConfig): + pass + +class DomainMetric(Metric): + pass + +class RangeMetric(Metric): + pass + +class RelationDirectionMetric(Metric): + pass + +class DatatypeMetric(Metric): + pass + +class DatatypeFormatMetric(Metric): + pass + +# class OntologyClassCoverageMetric(): +# pass + +# class OntologyRelationCoverageMetric(): +# pass + +# class OntologyNamespaceCoverageMetric(): +# pass \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/duplicates.py b/src/kgpipe_eval/metrics/duplicates.py index 3bb868c..1b05749 100644 --- a/src/kgpipe_eval/metrics/duplicates.py +++ b/src/kgpipe_eval/metrics/duplicates.py @@ -1,7 +1,46 @@ +from kgpipe.util.embeddings.st_emb import get_model +from kgpipe_eval.utils.alignment_utils import Alignment, align_entities_by_label_embedding +from kgpipe_eval.api import Metric, MetricResult, Measurement -def eval_duplicates(): - pass +from pydantic import BaseModel +from kgpipe.common import KG +import numpy as np +DEBUG = False + +class DuplicateConfig(BaseModel): + threshold: float = 0.5 + similarity_model: str = "cosine" + similarity_function: str = "cosine" + reference_kg: KG + +def eval_duplicates(kg: KG, config: DuplicateConfig): + """ + checks expected & integrated source entity overlap using label embeddings + """ + + alignments : list[Alignment] = align_entities_by_label_embedding(kg, ref_kg, threshold=config.threshold, model=config.similarity_model, similarity=config.similarity_function) + + duplicates = 0 + already_matched_references = set() + + for alignment in alignments: + if alignment.target in already_matched_references: + duplicates += 1 + already_matched_references.add(alignment.target) + + return duplicates + +class DuplicateMetric(Metric): + def compute(self, kg: KG, ref_kg: KG, config: DuplicateConfig): + duplicates = eval_duplicates(kg, ref_kg, config) + return MetricResult( + metric=self, + measurements=[ + Measurement(name="duplicates", value=duplicates, unit="number"), + ], + summary=f"Duplicates in the KG" + ) # find all duplicate entities in the KG # using diff --git a/src/kgpipe_eval/metrics/entity_alignment.py b/src/kgpipe_eval/metrics/entity_alignment.py new file mode 100644 index 0000000..8457fd0 --- /dev/null +++ b/src/kgpipe_eval/metrics/entity_alignment.py @@ -0,0 +1,45 @@ +from kgpipe_eval.utils.kg_utils import Term +from typing import NamedTuple +from kgpipe.util.embeddings.st_emb import get_model +from kgpipe.common import KG +from typing import Literal +from kgpipe_eval.utils.measurement_utils import BCMeasurement + +from kgpipe_eval.api import Metric +from pydantic import BaseModel +from rdflib import RDFS + +# TODO +# measures precision, recall, f1 score, etc. + +TODO = None + +def eval_entity_alignment(kg: KG, config: TODO): + pass + +def eval_entity_alignment_by_label_embedding(kg: KG, threshold: float = 0.5): + model = get_model() + # entity_dict = load_entity_dict(entity_dict_path) + # entity_labels = list(set([entity_dict[uri].entity_label for uri in entity_dict if entity_dict[uri].entity_label is not None])) + entity_labels = [] # TODO: get entity labels from the KG + entity_labels_embeddings = model.encode(entity_labels, convert_to_numpy=True, show_progress_bar=False) + + found_labels = [] + overlapping_entities = set() + overlapping_entities_strict = set() + + graph = kg.get_graph() + for s, p, label in graph.triples((None, RDFS.label, None)): + found_labels.append(str(label)) + + found_labels_embeddings = model.encode(found_labels, convert_to_numpy=True, show_progress_bar=False) + + return BCMeasurement( + tp=len(overlapping_entities), + fp=len(found_labels) - len(overlapping_entities), + tn=len(kg.entities) - len(found_labels), + fn=len(kg.entities) - len(overlapping_entities) + ) + +class EntityAlignmentMetric(Metric): + pass \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/llm_annotation.py b/src/kgpipe_eval/metrics/llm_annotation.py new file mode 100644 index 0000000..49b099a --- /dev/null +++ b/src/kgpipe_eval/metrics/llm_annotation.py @@ -0,0 +1,4 @@ +from kgpipe_eval.api import Metric + +class LLM_KgAccuracyMetric(Metric): + pass \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/reference_entities.py b/src/kgpipe_eval/metrics/reference_entities.py deleted file mode 100644 index 0b14d66..0000000 --- a/src/kgpipe_eval/metrics/reference_entities.py +++ /dev/null @@ -1,5 +0,0 @@ -from kgpipe_eval.metrics.alignment_utils import get_aligned_entities -from kgpipe.common import KG -from typing import Literal - -# TODO \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/statistics.py b/src/kgpipe_eval/metrics/statistics.py index e615e6c..6ce0c89 100644 --- a/src/kgpipe_eval/metrics/statistics.py +++ b/src/kgpipe_eval/metrics/statistics.py @@ -1,6 +1,6 @@ from kgpipe.common import KG -from kgpipe_eval.metrics.kg_utils import TripleGraph, KgManager -from kgpipe_eval.api import Metric, MetricResult, MetricConfig +from kgpipe_eval.utils.kg_utils import TripleGraph, KgManager +from kgpipe_eval.api import Metric, MetricResult, Measurement from functools import lru_cache from pydantic import BaseModel @@ -18,7 +18,7 @@ class CountMeasures(BaseModel): property_occurrence: Mapping[str, int] class_occurrence: Mapping[str, int] -@lru_cache(maxsize=1000) +@lru_cache(maxsize=1) def count_measures(kg: TripleGraph) -> CountMeasures: entity_count = 0 # TODO requires distinct entities @@ -44,4 +44,16 @@ def count_measures(kg: TripleGraph) -> CountMeasures: class CountMetric(Metric): def compute(self, kg: TripleGraph) -> MetricResult: - return MetricResult(value=count_measures(kg)) \ No newline at end of file + return MetricResult( + metric=self, + measurements=[ + Measurement(name="entity_count", value=count_measures(kg).entity_count, unit="number"), + Measurement(name="triple_count", value=count_measures(kg).triple_count, unit="number"), + Measurement(name="property_count", value=count_measures(kg).property_count, unit="number"), + Measurement(name="class_count", value=count_measures(kg).class_count, unit="number"), + Measurement(name="property_occurrence", value=count_measures(kg).property_occurrence, unit="number"), + Measurement(name="class_occurrence", value=count_measures(kg).class_occurrence, unit="number"), + ], + summary=f"Measures of entities, triples, properties, classes, property occurrences, and class occurrences" + ) + diff --git a/src/kgpipe_eval/metrics/triple_alignment.py b/src/kgpipe_eval/metrics/triple_alignment.py new file mode 100644 index 0000000..e43d50b --- /dev/null +++ b/src/kgpipe_eval/metrics/triple_alignment.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel +from typing import Literal + +from kgpipe.common import KG +from kgpipe_eval.utils.alignment_utils import Alignment + +# measures precision, recall, f1 score, etc. + +class TripleAlignmentConfig(BaseModel): + reference_kg: KG + similarity_threshold: float = 0.5 + similarity_model: str = "cosine" + similarity_function: str = "cosine" + +def eval_triple_alignment(method: Literal["exact", "fuzzy", "semantic"] = "exact"): + pass + +def eval_triple_alignment_by_label_embedding(method: Literal["exact", "fuzzy", "semantic"] = "exact"): + pass + + +class ReferenceTripleAlignmentMetric(Metric): + pass \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/entailment_utils.py b/src/kgpipe_eval/test/__init__.py similarity index 100% rename from src/kgpipe_eval/metrics/entailment_utils.py rename to src/kgpipe_eval/test/__init__.py diff --git a/src/kgpipe_eval/test/examples.py b/src/kgpipe_eval/test/examples.py new file mode 100644 index 0000000..3637490 --- /dev/null +++ b/src/kgpipe_eval/test/examples.py @@ -0,0 +1,25 @@ +TEST_TURTLE_TRIPLES = """ +@prefix : . +@prefix rdf: . +@prefix rdfs: . +@prefix xsd: . + +:itemA rdf:type :Book ; + rdfs:label "itemA" ; + :bookTitle "The Hobbit, or There and Back Again" ; + :bookAuthor :authorTolkien ; + :isbn13 "9780261102217" . +""" + +REFERENCE_TURTLE_TRIPLES = """ +@prefix : . +@prefix rdf: . +@prefix rdfs: . +@prefix xsd: . + +:itemA rdf:type :Book ; + rdfs:label "itemA" ; + :bookTitle "The Hobbit, or There and Back Again" ; + :bookAuthor :authorTolkien ; + :isbn13 "9780261102217" . +""" \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/reference_triples.py b/src/kgpipe_eval/test/test_alignment_eval.py similarity index 100% rename from src/kgpipe_eval/metrics/reference_triples.py rename to src/kgpipe_eval/test/test_alignment_eval.py diff --git a/src/kgpipe_eval/test/test_llm_eval.py b/src/kgpipe_eval/test/test_llm_eval.py new file mode 100644 index 0000000..c438e71 --- /dev/null +++ b/src/kgpipe_eval/test/test_llm_eval.py @@ -0,0 +1,6 @@ +import pytest + +@pytest.skip(reason="Long running test") +def test_llm_eval(): + pass + diff --git a/src/kgpipe_eval/test/test_source_eval.py b/src/kgpipe_eval/test/test_source_eval.py new file mode 100644 index 0000000..e69de29 diff --git a/src/kgpipe_eval/test/test_statistics_eval.py b/src/kgpipe_eval/test/test_statistics_eval.py new file mode 100644 index 0000000..2f1221a --- /dev/null +++ b/src/kgpipe_eval/test/test_statistics_eval.py @@ -0,0 +1,7 @@ +from kgpipe_eval.metrics.statistics import CountMetric +from kgpipe_eval.test.examples import TEST_TURTLE_TRIPLES, REFERENCE_TURTLE_TRIPLES + +def test_count_metric(): + metric = CountMetric() + report = metric.compute(TEST_TURTLE_TRIPLES) + render_metric_as_table(report, show_details=SHOW_DETAILS) \ No newline at end of file diff --git a/src/kgpipe_eval/test/utils.py b/src/kgpipe_eval/test/utils.py new file mode 100644 index 0000000..0e2b72c --- /dev/null +++ b/src/kgpipe_eval/test/utils.py @@ -0,0 +1,11 @@ +from pathlib import Path +from kgpipe.common import KG + +from kgpipe_eval.test.examples import * + +def get_test_kg() -> KG: + return KG() + +def get_reference_kg() -> KG: + return KG() + diff --git a/src/kgpipe_eval/utils/__init__.py b/src/kgpipe_eval/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/kgpipe_eval/utils/alignment_utils.py b/src/kgpipe_eval/utils/alignment_utils.py new file mode 100644 index 0000000..8f8d0fa --- /dev/null +++ b/src/kgpipe_eval/utils/alignment_utils.py @@ -0,0 +1,68 @@ +from kgpipe.common import KG +from typing import Literal, NamedTuple +from functools import lru_cache +from pydantic import BaseModel + +from kgpipe_eval.utils.kg_utils import TripleGraph, Term, Triple +from kgpipe.util.embeddings.st_emb import get_model + +from rdflib import RDFS, RDF +import numpy as np + +class AlignmentConfig(BaseModel): + model: str = "sentence-transformer" + similarity: str = "cosine" + threshold: float = 0.5 + +# TODO source entities csv to label only graph + +CONFIG=None +# layz config dict +def get_config() -> dict: + global CONFIG + if CONFIG is None: + # TODO + pass + return CONFIG + +EntityAlignment = NamedTuple("EntityAlignment", [("source", Term), ("target", Term)]) +TripleAlignment = NamedTuple("TripleAlignment", [("source", Triple), ("target", Triple)]) + +# Core alignment method interfaces + +@lru_cache(maxsize=1000) +def get_aligned_entities(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Entity]: + return kg.entities.intersection(reference_kg.entities) + +def get_aligned_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Triple]: + return kg.triples.intersection(reference_kg.triples) + +# Helper methods + +def get_entity_uri_label_pairs(triple_graph: TripleGraph) -> list[tuple[Term, Term]]: + return [(s, label) for s, _, label in triple_graph.triples((None, RDFS.label, None))] + +# Specific alignment methods + +def align_entities_by_label_embedding(triple_graph: TripleGraph, ref_triple_graph: TripleGraph, model="TODO", similarity="cosine", threshold=0.5): + model = get_model() + ref_entity_labels = [str(label) for s, _, label in ref_triple_graph.triples((None, RDFS.label, None))] + ref_entity_labels_embeddings = model.encode(ref_entity_labels, convert_to_numpy=True, show_progress_bar=False) + + gen_entity_labels = [str(label) for s, _, label in triple_graph.triples((None, RDFS.label, None))] + gen_entity_labels_embeddings = model.encode(gen_entity_labels, convert_to_numpy=True, show_progress_bar=False) + + for s, _, label in triple_graph.triples((None, RDFS.label, None)): + gen_entity_labels.append(str(label)) + + sims = np.dot(gen_entity_labels_embeddings, ref_entity_labels_embeddings.T) + + alignments = [] + for i in range(sims.shape[0]): + best_j = np.argmax(sims[i]) + if sims[i][best_j] >= threshold: + alignments.append(EntityAlignment(source=gen_entity_labels[i], target=ref_entity_labels[best_j], score=sims[i][best_j])) + return alignments + +def align_by_label_alias_embedding(triple_graph: TripleGraph, model="", similarity="cosine", threshold=0.5): + pass diff --git a/src/kgpipe_eval/utils/annotation_utils.py b/src/kgpipe_eval/utils/annotation_utils.py new file mode 100644 index 0000000..c196a77 --- /dev/null +++ b/src/kgpipe_eval/utils/annotation_utils.py @@ -0,0 +1,51 @@ +from typing import Literal + +# Labels + +def get_labeled_entities(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Entity]: + return kg.entities.intersection(reference_kg.entities) + +def get_labeled_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Triple]: + return kg.triples.intersection(reference_kg.triples) + + +def label_triples_with_llm(Triple): + """ +You are validating RDF triples. + +Task 1: +For each triple, decide whether it is: +- plausible in isolation +- implausible in isolation +- unclear + +Task 2: +Considering that all triples refer to the same subject node, decide whether the set is: +- coherent +- ambiguous +- conflated +- temporally inconsistent +- geographically inconsistent + +Task 3: +Explain which triples are mutually incompatible and why. +{ + "triple_labels": [ + { + "triple": ":Paris :locatedIn :France .", + "label": "plausible_in_isolation" + }, + { + "triple": ":Paris :population \"2,100,000\" .", + "label": "plausible_in_isolation" + }, + { + "triple": ":Paris :locatedIn :Texas .", + "label": "plausible_in_isolation" + } + ], + "entity_label": "conflated", + "graph_label": "contextually_incompatible", + "explanation": "The subject :Paris appears to merge Paris, France and Paris, Texas." +} + """ \ No newline at end of file diff --git a/src/kgpipe_eval/utils/entailment_utils.py b/src/kgpipe_eval/utils/entailment_utils.py new file mode 100644 index 0000000..19e8cb0 --- /dev/null +++ b/src/kgpipe_eval/utils/entailment_utils.py @@ -0,0 +1,7 @@ + + +def check_entailment(): + pass + +def check_entailment_by_llm(): + pass \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/kg_utils.py b/src/kgpipe_eval/utils/kg_utils.py similarity index 85% rename from src/kgpipe_eval/metrics/kg_utils.py rename to src/kgpipe_eval/utils/kg_utils.py index bfd1232..13dbcde 100644 --- a/src/kgpipe_eval/metrics/kg_utils.py +++ b/src/kgpipe_eval/utils/kg_utils.py @@ -4,14 +4,11 @@ from pathlib import Path from typing import Iterable, Protocol, Union, runtime_checkable, Optional, Tuple, Literal -from rdflib import RDF, Graph -from rdflib.term import Identifier -from rdflib.term import Literal -from rdflib.term import URIRef +from rdflib import RDF, Graph, RDFS +from rdflib.term import Identifier, Literal, URIRef from kgpipe.common import KG - KgLike = Union[KG, Graph, str, Path] Term = Union[Identifier, str, URIRef, Literal] @@ -35,6 +32,15 @@ class TripleGraph(Protocol): def triples(self, triple_pattern: TriplePattern) -> Iterable[Triple]: pass + def subjects(self) -> Iterable[Term]: + pass + + def labels(self, term: Term) -> Literal: + pass + + def types(self, term: Term) -> Iterable[Term]: + pass + def close(self) -> None: pass @@ -106,6 +112,18 @@ def triples(self, triple_pattern: TriplePattern) -> Iterable[Triple]: # RDFLib yields (s, p, o) as Identifiers return g.triples(triple_pattern) + def subjects(self) -> Iterable[Term]: + g = self._graph() + return g.subjects(unique=True) + + def labels(self, term: Term) -> Literal: + g = self._graph() + return g.triples((term, RDFS.label, None)) + + def types(self, term: Term) -> Iterable[Term]: + g = self._graph() + return g.triples((term, RDF.type, None)) + class KgManager: """ KgManager is a class that manages the loading and unloading of KGs. diff --git a/src/kgpipe_eval/utils/measurement_utils.py b/src/kgpipe_eval/utils/measurement_utils.py new file mode 100644 index 0000000..8cca7e7 --- /dev/null +++ b/src/kgpipe_eval/utils/measurement_utils.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel + +class BinaryClassificationMeasurement(BaseModel): + tp: int + fp: int + tn: int + fn: int + + def accuracy(self) -> float: + return (self.tp + self.tn) / (self.tp + self.tn + self.fp + self.fn) + + def precision(self) -> float: + return self.tp / (self.tp + self.fp) + + def recall(self) -> float: + return self.tp / (self.tp + self.fn) + + def f1_score(self) -> float: + return 2 * self.precision() * self.recall() / (self.precision() + self.recall()) + + def __str__(self): + return f"tp: {self.tp}, fp: {self.fp}, tn: {self.tn}, fn: {self.fn}, accuracy: {self.accuracy()}, precision: {self.precision()}, recall: {self.recall()}, f1_score: {self.f1_score()}" + + def __dict__(self): + return { + "tp": self.tp, + "fp": self.fp, + "tn": self.tn, + "fn": self.fn, + "accuracy": self.accuracy(), + "precision": self.precision(), + "recall": self.recall(), + "f1_score": self.f1_score() + } + +BCMeasurement = BinaryClassificationMeasurement \ No newline at end of file diff --git a/src/kgpipe_eval/utils/verbalize_utils.py b/src/kgpipe_eval/utils/verbalize_utils.py new file mode 100644 index 0000000..7b31cfc --- /dev/null +++ b/src/kgpipe_eval/utils/verbalize_utils.py @@ -0,0 +1,16 @@ +from kgpipe_eval.utils.kg_utils import Triple, TripleGraph, TriplePattern + +def verbalize_triple_simple(triple: Triple, TripleGraph) -> str: + """ + using label of subject, predicate, object to verbalize the triple + """ + return f"{triple[0]} {triple[1]} {triple[2]}" + +def verbalize_triples(triples: list[Triple]) -> list[str]: + pass + +def verbalize_triple_graph(triple_graph: TripleGraph) -> list[str]: + pass + +def verbalize_triple_graph_subject_groups(triple_graph: TripleGraph) -> list[list[str]]: + pass \ No newline at end of file From 9454790bbf486924decf87d67f3432681bd91aa6 Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 8 Apr 2026 15:58:50 +0200 Subject: [PATCH 10/42] feat(eval): added refactored metric impls --- src/kgpipe_eval/api.py | 23 ++-- src/kgpipe_eval/metrics/__init__.py | 26 +++- .../metrics/consistency_violations.py | 40 +++--- src/kgpipe_eval/metrics/duplicates.py | 41 ++++--- src/kgpipe_eval/metrics/entity_alignment.py | 114 +++++++++++++----- src/kgpipe_eval/metrics/statistics.py | 39 ++++-- src/kgpipe_eval/metrics/triple_alignment.py | 25 ++-- 7 files changed, 219 insertions(+), 89 deletions(-) diff --git a/src/kgpipe_eval/api.py b/src/kgpipe_eval/api.py index 8b37e9e..36c821a 100644 --- a/src/kgpipe_eval/api.py +++ b/src/kgpipe_eval/api.py @@ -1,8 +1,8 @@ -from dataclasses import dataclass -from functools import lru_cache +from __future__ import annotations + from abc import ABC, abstractmethod -from typing import Callable -from kgpipe.common.model.kg import KgKg +from dataclasses import dataclass +from typing import Any # MetricConfig (rich, typed, input) @@ -20,7 +20,7 @@ class MetricConfig: @dataclass(frozen=True) class Measurement: name: str - value: int | float | str | bool + value: Any unit: str | None = None @dataclass(frozen=True) @@ -30,11 +30,18 @@ class MetricResult: summary: str | None = None # TODO metadata/properties: dict[str, int | float | str | bool] = field(default_factory=dict) -@dataclass(frozen=True) -class Metric: +class Metric(ABC): + """ + Minimal metric interface for the `kgpipe eval-new` CLI. + + Metrics are instantiated (usually with default config) and then run via `compute(...)`. + """ + key: str description: str - compute: Callable[[KgKg, MetricConfig], MetricResult] + + @abstractmethod + def compute(self, *args: Any, **kwargs: Any) -> MetricResult: ... # --- diff --git a/src/kgpipe_eval/metrics/__init__.py b/src/kgpipe_eval/metrics/__init__.py index a1ccd07..fb29624 100644 --- a/src/kgpipe_eval/metrics/__init__.py +++ b/src/kgpipe_eval/metrics/__init__.py @@ -1,4 +1,28 @@ - +from .statistics import CountMetric +from .triple_alignment import TripleAlignmentMetric +from .entity_alignment import EntityAlignmentMetric +from .duplicates import DuplicateMetric +from .consistency_violations import ( + DisjointDomainMetric, + DomainMetric, + RangeMetric, + RelationDirectionMetric, + DatatypeMetric, + DatatypeFormatMetric, +) + +__all__ = [ + "CountMetric", + "TripleAlignmentMetric", + "EntityAlignmentMetric", + "DuplicateMetric", + "DisjointDomainMetric", + "DomainMetric", + "RangeMetric", + "RelationDirectionMetric", + "DatatypeMetric", + "DatatypeFormatMetric", +] # @dataclass(frozen=True) # class BinaryClassificationStats: diff --git a/src/kgpipe_eval/metrics/consistency_violations.py b/src/kgpipe_eval/metrics/consistency_violations.py index 7cd1141..6e0ddd4 100644 --- a/src/kgpipe_eval/metrics/consistency_violations.py +++ b/src/kgpipe_eval/metrics/consistency_violations.py @@ -1,38 +1,44 @@ from kgpipe_eval.api import Metric -from pydantic import BaseModel +from pydantic import BaseModel, model_validator, ConfigDict from kgpipe.common import KG +from pathlib import Path +from kgpipe_eval.utils.kg_utils import TripleGraph -class DisjointDomainConfig(BaseModel): - pass +class ConsistencyViolationsConfig(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + reference_kg: KG + ontology_path: Path -class DomainConfig(BaseModel): - pass - -class RangeConfig(BaseModel): - pass - -class RelationDirectionConfig(BaseModel): - pass + @model_validator(mode="after") + def _require_reference_kg_or_ontology_path(self): + if self.reference_kg is None and self.ontology_path is None: + raise ValueError("Provide either `reference_kg` or `ontology_path`.") + return self class DisjointDomainMetric(Metric): - def compute(self, kg: KG, ref_kg: KG, config: DisjointDomainConfig): + def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): pass class DomainMetric(Metric): - pass + def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): + pass class RangeMetric(Metric): - pass + def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): + pass class RelationDirectionMetric(Metric): - pass + def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): + pass class DatatypeMetric(Metric): - pass + def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): + pass class DatatypeFormatMetric(Metric): - pass + def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): + pass # class OntologyClassCoverageMetric(): # pass diff --git a/src/kgpipe_eval/metrics/duplicates.py b/src/kgpipe_eval/metrics/duplicates.py index 1b05749..869f3ff 100644 --- a/src/kgpipe_eval/metrics/duplicates.py +++ b/src/kgpipe_eval/metrics/duplicates.py @@ -1,43 +1,56 @@ -from kgpipe.util.embeddings.st_emb import get_model -from kgpipe_eval.utils.alignment_utils import Alignment, align_entities_by_label_embedding +from kgpipe_eval.utils.alignment_utils import EntityAlignment, align_entities_by_label_embedding, EntityAlignmentConfig from kgpipe_eval.api import Metric, MetricResult, Measurement +from kgpipe_eval.utils.kg_utils import Term, TripleGraph -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from kgpipe.common import KG import numpy as np DEBUG = False class DuplicateConfig(BaseModel): - threshold: float = 0.5 - similarity_model: str = "cosine" - similarity_function: str = "cosine" - reference_kg: KG + model_config = ConfigDict(arbitrary_types_allowed=True) + entity_alignment_config: EntityAlignmentConfig -def eval_duplicates(kg: KG, config: DuplicateConfig): +class DuplicateMeasures(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + duplicates: int + total_references: int + already_matched_references: set[Term] + +def eval_duplicates(kg: TripleGraph, config: DuplicateConfig): """ checks expected & integrated source entity overlap using label embeddings """ - alignments : list[Alignment] = align_entities_by_label_embedding(kg, ref_kg, threshold=config.threshold, model=config.similarity_model, similarity=config.similarity_function) + alignments : list[EntityAlignment] = align_entities_by_label_embedding(kg, config.entity_alignment_config) - duplicates = 0 + duplicates = set() already_matched_references = set() for alignment in alignments: if alignment.target in already_matched_references: - duplicates += 1 + duplicates.add(alignment.target) already_matched_references.add(alignment.target) + if DEBUG: + print("Duplicates:") + for alignment in alignments: + if alignment.target in duplicates: + print(alignment.target, alignment.source, alignment.score) + return duplicates class DuplicateMetric(Metric): - def compute(self, kg: KG, ref_kg: KG, config: DuplicateConfig): - duplicates = eval_duplicates(kg, ref_kg, config) + def compute(self, kg: TripleGraph, config: DuplicateConfig): + duplicates = eval_duplicates(kg, config) + entity_count = len(list(kg.entities())) return MetricResult( metric=self, measurements=[ - Measurement(name="duplicates", value=duplicates, unit="number"), + Measurement(name="duplicates", value=len(duplicates), unit="number"), + Measurement(name="entity_count", value=entity_count, unit="number"), + Measurement(name="duplicates_ratio", value=len(duplicates) / entity_count, unit="percentage"), ], summary=f"Duplicates in the KG" ) diff --git a/src/kgpipe_eval/metrics/entity_alignment.py b/src/kgpipe_eval/metrics/entity_alignment.py index 8457fd0..ae68f40 100644 --- a/src/kgpipe_eval/metrics/entity_alignment.py +++ b/src/kgpipe_eval/metrics/entity_alignment.py @@ -1,45 +1,99 @@ -from kgpipe_eval.utils.kg_utils import Term -from typing import NamedTuple -from kgpipe.util.embeddings.st_emb import get_model from kgpipe.common import KG -from typing import Literal + +from kgpipe_eval.api import Metric, Measurement, MetricResult + from kgpipe_eval.utils.measurement_utils import BCMeasurement +from kgpipe_eval.utils.alignment_utils import align_entities_by_label_embedding, EntityAlignmentConfig, load_entity_uri_label_type_pairs, get_entity_uri_label_type_pairs + +# Core Interface + +def eval_entity_alignment(kg: KG, config: EntityAlignmentConfig): + if config.method == "label_embedding": + alignments = eval_entity_alignment_by_label_embedding(kg, config) + elif config.method == "label_alias_embedding": + alignments = eval_entity_alignment_by_label_alias_embedding(kg, config) + elif config.method == "label_embedding_and_type": + alignments = eval_entity_alignment_by_label_embedding_and_type(kg, config) + else: + raise ValueError(f"Invalid method: {config.method}") + return alignments + +# Specific Implementations + +def eval_entity_alignment_by_label_embedding_and_type(kg: KG, config: EntityAlignmentConfig): + alignments = align_entities_by_label_embedding(kg, config) -from kgpipe_eval.api import Metric -from pydantic import BaseModel -from rdflib import RDFS + ref_entity_uri_label_type_pairs = load_entity_uri_label_type_pairs(config) + gen_entity_uri_label_type_pairs = list(get_entity_uri_label_type_pairs(kg)) -# TODO -# measures precision, recall, f1 score, etc. + ref_types = {pair.uri: pair.type for pair in ref_entity_uri_label_type_pairs if pair.type is not None} + # TODO gen_types can be multiple types, we need to handle this + gen_types = {pair.uri: pair.type for pair in gen_entity_uri_label_type_pairs if pair.type is not None} -TODO = None + filtered_alignments = [] + for alignment in alignments: + if alignment.target in ref_types and alignment.source in gen_types: + if ref_types[alignment.target] == gen_types[alignment.source]: + filtered_alignments.append(alignment) -def eval_entity_alignment(kg: KG, config: TODO): - pass + ref_uris = set(pair.uri for pair in ref_entity_uri_label_type_pairs) + gen_uris = set(pair.uri for pair in gen_entity_uri_label_type_pairs) + aligned_gen_uris = set(alignment.target for alignment in filtered_alignments) + aligned_ref_uris = set(alignment.source for alignment in filtered_alignments) -def eval_entity_alignment_by_label_embedding(kg: KG, threshold: float = 0.5): - model = get_model() - # entity_dict = load_entity_dict(entity_dict_path) - # entity_labels = list(set([entity_dict[uri].entity_label for uri in entity_dict if entity_dict[uri].entity_label is not None])) - entity_labels = [] # TODO: get entity labels from the KG - entity_labels_embeddings = model.encode(entity_labels, convert_to_numpy=True, show_progress_bar=False) + tp = len(ref_uris & aligned_gen_uris) # generated entities that are also in the reference + fp = len(gen_uris - aligned_ref_uris) # generated entities that are not in the reference + tn = 0 + fn = len(ref_uris - aligned_gen_uris) # missing generated entities that are in the reference - found_labels = [] - overlapping_entities = set() - overlapping_entities_strict = set() + return BCMeasurement( + tp=tp, + fp=fp, + tn=tn, + fn=fn + ) + +def eval_entity_alignment_by_label_embedding(kg: KG, config: EntityAlignmentConfig): + alignments = align_entities_by_label_embedding(kg, config) - graph = kg.get_graph() - for s, p, label in graph.triples((None, RDFS.label, None)): - found_labels.append(str(label)) + ref_entity_uri_label_type_pairs = load_entity_uri_label_type_pairs(config) + gen_entity_uri_label_type_pairs = list(get_entity_uri_label_type_pairs(kg)) - found_labels_embeddings = model.encode(found_labels, convert_to_numpy=True, show_progress_bar=False) + ref_uris = set(pair.uri for pair in ref_entity_uri_label_type_pairs) + gen_uris = set(pair.uri for pair in gen_entity_uri_label_type_pairs) + aligned_gen_uris = set(alignment.target for alignment in alignments) + aligned_ref_uris = set(alignment.source for alignment in alignments) + + tp = len(ref_uris & aligned_gen_uris) # generated entities that are also in the reference + fp = len(gen_uris - aligned_ref_uris) # generated entities that are not in the reference + tn = 0 + fn = len(ref_uris - aligned_gen_uris) # missing generated entities that are in the reference return BCMeasurement( - tp=len(overlapping_entities), - fp=len(found_labels) - len(overlapping_entities), - tn=len(kg.entities) - len(found_labels), - fn=len(kg.entities) - len(overlapping_entities) + tp=tp, + fp=fp, + tn=tn, + fn=fn ) +def eval_entity_alignment_by_label_alias_embedding(kg: KG, config: EntityAlignmentConfig): + raise NotImplementedError("Label alias embedding alignment is not implemented yet") + +# Metric Implementation + class EntityAlignmentMetric(Metric): - pass \ No newline at end of file + def compute(self, kg: KG, config: EntityAlignmentConfig): + alignments: BCMeasurement = eval_entity_alignment(kg, config) + return MetricResult( + metric=self, + measurements=[ + Measurement(name="tp", value=alignments.tp, unit="number"), + Measurement(name="fp", value=alignments.fp, unit="number"), + Measurement(name="tn", value=alignments.tn, unit="number"), + Measurement(name="fn", value=alignments.fn, unit="number"), + Measurement(name="precision", value=alignments.precision(), unit="percentage"), + Measurement(name="recall", value=alignments.recall(), unit="percentage"), + Measurement(name="f1_score", value=alignments.f1_score(), unit="percentage"), + ], + summary=f"Entity alignment by {config.method}" + ) \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/statistics.py b/src/kgpipe_eval/metrics/statistics.py index 6ce0c89..191776d 100644 --- a/src/kgpipe_eval/metrics/statistics.py +++ b/src/kgpipe_eval/metrics/statistics.py @@ -1,5 +1,4 @@ -from kgpipe.common import KG -from kgpipe_eval.utils.kg_utils import TripleGraph, KgManager +from kgpipe_eval.utils.kg_utils import TripleGraph from kgpipe_eval.api import Metric, MetricResult, Measurement from functools import lru_cache @@ -18,14 +17,17 @@ class CountMeasures(BaseModel): property_occurrence: Mapping[str, int] class_occurrence: Mapping[str, int] -@lru_cache(maxsize=1) +# @lru_cache(maxsize=1) def count_measures(kg: TripleGraph) -> CountMeasures: - entity_count = 0 # TODO requires distinct entities triple_count = 0 + subject_count = 0 # TODO misses shallow object entities class_occurrence = defaultdict(int) property_occurrence = defaultdict(int) + + for _ in kg.subjects(): + subject_count += 1 for s, p, o in kg.triples((None, None, None)): triple_count += 1 @@ -34,7 +36,7 @@ def count_measures(kg: TripleGraph) -> CountMeasures: property_occurrence[str(p)] += 1 return CountMeasures( - entity_count=entity_count, + entity_count=subject_count, property_count=len(property_occurrence.keys()), triple_count=triple_count, class_count=len(class_occurrence.keys()), @@ -43,17 +45,32 @@ def count_measures(kg: TripleGraph) -> CountMeasures: ) class CountMetric(Metric): + key = "CountMetric" + description = "Counts triples/classes/properties (basic statistics)." + def compute(self, kg: TripleGraph) -> MetricResult: + counts = count_measures(kg) return MetricResult( metric=self, measurements=[ - Measurement(name="entity_count", value=count_measures(kg).entity_count, unit="number"), - Measurement(name="triple_count", value=count_measures(kg).triple_count, unit="number"), - Measurement(name="property_count", value=count_measures(kg).property_count, unit="number"), - Measurement(name="class_count", value=count_measures(kg).class_count, unit="number"), - Measurement(name="property_occurrence", value=count_measures(kg).property_occurrence, unit="number"), - Measurement(name="class_occurrence", value=count_measures(kg).class_occurrence, unit="number"), + Measurement(name="entity_count", value=counts.entity_count, unit="number"), + Measurement(name="triple_count", value=counts.triple_count, unit="number"), + Measurement(name="property_count", value=counts.property_count, unit="number"), + Measurement(name="class_count", value=counts.class_count, unit="number"), + Measurement(name="property_occurrence", value=counts.property_occurrence, unit="dictionary"), + Measurement(name="class_occurrence", value=counts.class_occurrence, unit="dictionary"), ], summary=f"Measures of entities, triples, properties, classes, property occurrences, and class occurrences" ) +class DegreeMetric(Metric): + # def compute(self, kg: TripleGraph) -> MetricResult: + # degrees = degree_measures(kg) + # return MetricResult( + # metric=self, + # measurements=[ + # Measurement(name="degree", value=degrees.degree, unit="number"), + # ], + # summary=f"Measures of degrees" + # ) + pass \ No newline at end of file diff --git a/src/kgpipe_eval/metrics/triple_alignment.py b/src/kgpipe_eval/metrics/triple_alignment.py index e43d50b..7934ff6 100644 --- a/src/kgpipe_eval/metrics/triple_alignment.py +++ b/src/kgpipe_eval/metrics/triple_alignment.py @@ -1,23 +1,32 @@ -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from typing import Literal from kgpipe.common import KG -from kgpipe_eval.utils.alignment_utils import Alignment +from kgpipe_eval.metrics.entity_alignment import EntityAlignmentConfig +from kgpipe_eval.utils.measurement_utils import BCMeasurement +from kgpipe_eval.api import Metric # measures precision, recall, f1 score, etc. class TripleAlignmentConfig(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) reference_kg: KG - similarity_threshold: float = 0.5 - similarity_model: str = "cosine" - similarity_function: str = "cosine" + entity_alignment_config: EntityAlignmentConfig + value_sim_threshold: float = 0.5 -def eval_triple_alignment(method: Literal["exact", "fuzzy", "semantic"] = "exact"): - pass +# def eval_triple_alignment(method: Literal["exact", "fuzzy", "semantic"] = "exact"): +# pass def eval_triple_alignment_by_label_embedding(method: Literal["exact", "fuzzy", "semantic"] = "exact"): pass +def eval_triple_alignment_by_label_embedding_soft_literals(method: Literal["exact", "fuzzy", "semantic"] = "exact"): + pass + class ReferenceTripleAlignmentMetric(Metric): - pass \ No newline at end of file + pass + + +# Backward-compatibility alias (imported by `kgpipe_eval.metrics.__init__`). +TripleAlignmentMetric = ReferenceTripleAlignmentMetric \ No newline at end of file From 4bb2c61232ce8f205015f045b0d90c97b9d0224a Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 8 Apr 2026 15:59:30 +0200 Subject: [PATCH 11/42] feat(eval): yaml config loader and evaluator impl --- src/kgpipe/cli/eval_new.py | 175 +++++++++++++++++++++++++ src/kgpipe_eval/config/manager.py | 208 ++++++++++++++++++++++++++++++ src/kgpipe_eval/evaluator.py | 65 ++++++++++ 3 files changed, 448 insertions(+) create mode 100644 src/kgpipe/cli/eval_new.py create mode 100644 src/kgpipe_eval/config/manager.py create mode 100644 src/kgpipe_eval/evaluator.py diff --git a/src/kgpipe/cli/eval_new.py b/src/kgpipe/cli/eval_new.py new file mode 100644 index 0000000..a11eb17 --- /dev/null +++ b/src/kgpipe/cli/eval_new.py @@ -0,0 +1,175 @@ +import click +from rich.console import Console +from rich.table import Table +from typing import List, Optional, Sequence, Any +import json +from pathlib import Path + +from kgpipe_eval.metrics.statistics import CountMetric +from kgpipe_eval.metrics.duplicates import DuplicateMetric +from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric +from kgpipe_eval.utils.kg_utils import KgManager +from kgpipe_eval.config.manager import load_metric_configs +from kgpipe_eval.evaluator import Evaluator +# from kgpipe_eval.metrics.semantic import OntologyClassCoverageMetric, OntologyRelationCoverageMetric, OntologyNamespaceCoverageMetric +# from kgpipe_eval.metrics.reference import PrecisionMetric, RecallMetric, F1ScoreMetric +# from kgpipe_eval.metrics.efficiency import RuntimeMetric, MemoryUsageMetric, CostMetric +# from kgpipe_eval.metrics.quality import QualityMetric +# from kgpipe_eval.metrics.completeness import CompletenessMetric +# from kgpipe_eval.metrics.accuracy import AccuracyMetric + +console = Console() + + +def _available_metric_instances() -> dict[str, Any]: + # Keep this explicit until the metrics package is more complete/stable. + return { + "CountMetric": CountMetric(), + "DuplicateMetric": DuplicateMetric(), + "EntityAlignmentMetric": EntityAlignmentMetric(), + } + +def _normalize_key(k: str) -> str: + return k.strip().lower().replace("-", "_") + + +def _metric_key(metric: Any) -> str: + return getattr(metric, "key", metric.__class__.__name__) + + +def _build_confs_for_selected_metrics( + selected_metric_instances: list[Any], + loaded_confs: dict[str, Any], +) -> dict[str, Any]: + """ + Convert configs loaded from YAML (keyed by YAML metric id) into a dict keyed by + metric class name / `.key` (what Evaluator uses). + """ + confs_by_norm = {_normalize_key(k): v for k, v in loaded_confs.items()} + out: dict[str, Any] = {} + + # Common YAML → class-name aliases + alias_to_metric_key: dict[str, str] = { + "duplicates": "DuplicateMetric", + "duplicate": "DuplicateMetric", + "entity_align": "EntityAlignmentMetric", + "entity_alignment": "EntityAlignmentMetric", + } + + for metric in selected_metric_instances: + mkey = _metric_key(metric) + norm_mkey = _normalize_key(mkey) + norm_cls = _normalize_key(metric.__class__.__name__) + + cfg = ( + confs_by_norm.get(norm_mkey) + or confs_by_norm.get(norm_cls) + or confs_by_norm.get(_normalize_key(alias_to_metric_key.get(norm_mkey, ""))) + or confs_by_norm.get(_normalize_key(alias_to_metric_key.get(norm_cls, ""))) + or confs_by_norm.get(norm_mkey.replace("metric", "")) + or confs_by_norm.get(norm_cls.replace("metric", "")) + ) + + if cfg is not None: + out[mkey] = cfg + out[metric.__class__.__name__] = cfg + + return out + + + +def _render_results_table(kg_path: str, metric_key: str, measurements: Sequence[Any], summary: Optional[str]) -> None: + table = Table(title=f"{Path(kg_path).name} — {metric_key}") + table.add_column("Measurement", style="cyan") + table.add_column("Value", style="green") + table.add_column("Unit", style="magenta") + + for m in measurements: + unit = getattr(m, "unit", None) + value = getattr(m, "value", None) + name = getattr(m, "name", None) + table.add_row(str(name), json.dumps(value, ensure_ascii=False, default=str) if not isinstance(value, (str, int, float, bool)) else str(value), "" if unit is None else str(unit)) + + console.print(table) + if summary: + console.print(f"[dim]{summary}[/dim]") + console.print("") + + +def _results_to_json_rows(kg_path: str, metric_key: str, measurements: Sequence[Any], summary: Optional[str]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for m in measurements: + rows.append( + { + "kg_path": kg_path, + "metric": metric_key, + "measurement": getattr(m, "name", None), + "value": getattr(m, "value", None), + "unit": getattr(m, "unit", None), + "summary": summary, + } + ) + return rows + + +@click.command() +@click.argument("kg_paths", nargs=-1, type=click.Path(exists=True)) +@click.option( + "--config", + "-c", + type=click.Path(exists=True), + help="Path to metric config file" +) +@click.option( + "--metrics", + "-m", + multiple=True, + type=click.Choice(sorted(_available_metric_instances().keys())), + help="Metrics to compute" +) +@click.option( + "--output", + "-o", + type=click.Path(dir_okay=False), + help="Write results to a JSON file (list of measurement rows).", +) +@click.pass_context +def eval_new_cmd(ctx: click.Context, kg_paths: List[str], config: Optional[str], metrics: tuple, output: Optional[str]): + """ + Compute selected metrics for one or more KGs. + + KG_PATHS: one or more RDF files/directories that RDFLib can parse. + """ + metric_instances = _available_metric_instances() + selected_metrics = list(metrics) if metrics else list(metric_instances.keys()) + + unknown = [m for m in selected_metrics if m not in metric_instances] + if unknown: + raise click.ClickException(f"Unknown metrics: {', '.join(unknown)}") + + loaded_metric_confs: dict[str, Any] = {} + if config: + loaded_metric_confs = load_metric_configs(config) + + all_rows: list[dict[str, Any]] = [] + + for kg_path in kg_paths: + console.print(f"[bold blue]Evaluating:[/bold blue] {kg_path}") + kg_graph = KgManager.load_kg_from_path(Path(kg_path)) + try: + selected_metric_instances = [metric_instances[k] for k in selected_metrics] + confs = _build_confs_for_selected_metrics(selected_metric_instances, loaded_metric_confs) + + results = Evaluator().run(kg=kg_graph, metrics=selected_metric_instances, confs=confs) + for res in results: + metric_key = _metric_key(res.metric) + _render_results_table(kg_path, metric_key, res.measurements, getattr(res, "summary", None)) + all_rows.extend(_results_to_json_rows(kg_path, metric_key, res.measurements, getattr(res, "summary", None))) + finally: + KgManager.unload_kg(kg_graph) + + if output: + out_path = Path(output) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(all_rows, indent=2, ensure_ascii=False, default=str) + "\n", encoding="utf-8") + console.print(f"[green]✓ Saved results to[/green] {output}") \ No newline at end of file diff --git a/src/kgpipe_eval/config/manager.py b/src/kgpipe_eval/config/manager.py new file mode 100644 index 0000000..c245681 --- /dev/null +++ b/src/kgpipe_eval/config/manager.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Mapping, MutableMapping + +import yaml +from pydantic import BaseModel + +from kgpipe.common import KG +from kgpipe.common.model.data import DataFormat + +from kgpipe_eval.metrics.duplicates import DuplicateConfig +from kgpipe_eval.metrics.triple_alignment import TripleAlignmentConfig +from kgpipe_eval.metrics.consistency_violations import ConsistencyViolationsConfig +from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig + + +MetricConfigModel = BaseModel + + +def _deep_merge_dict(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]: + """ + Merge override into base recursively (override wins). + """ + out: dict[str, Any] = dict(base) + for k, v in override.items(): + if ( + k in out + and isinstance(out[k], Mapping) + and isinstance(v, Mapping) + ): + out[k] = _deep_merge_dict(out[k], v) + else: + out[k] = v + return out + + +def _kg_from_path(path: Path, *, name: str | None = None) -> KG: + """ + Build a minimal `kgpipe.common.KG` from a filesystem path. + + Notes: + - We infer `format` from the file suffix when possible, otherwise fall back to JSON. + - The KG object lazily parses the graph when `get_graph()` is called. + """ + suffix = path.suffix.lower().lstrip(".") + try: + fmt = DataFormat(suffix) + except Exception: + fmt = DataFormat.JSON + + return KG( + id=str(path), + name=(name or path.stem), + path=path, + format=fmt, + ) + + +def _resolve_entity_alignment_config( + metric_cfg: Mapping[str, Any], + named: Mapping[str, Mapping[str, Any]], +) -> dict[str, Any]: + """ + Resolve an entity alignment config from either: + - inline: `entity_alignment_config: {...}` + - ref: `entity_alignment_config_ref: name` + Optionally supports both; inline values override the referenced dict. + """ + inline = metric_cfg.get("entity_alignment_config") or {} + ref_name = metric_cfg.get("entity_alignment_config_ref") + if ref_name is None: + if not isinstance(inline, Mapping): + raise TypeError("`entity_alignment_config` must be a mapping if provided.") + return dict(inline) + + if not isinstance(ref_name, str) or not ref_name: + raise TypeError("`entity_alignment_config_ref` must be a non-empty string.") + if ref_name not in named: + raise KeyError(f"Unknown entity alignment config ref: {ref_name!r}") + + if not isinstance(inline, Mapping): + raise TypeError("`entity_alignment_config` must be a mapping if provided.") + return _deep_merge_dict(named[ref_name], inline) + + +def load_metric_configs(config_path: str | Path) -> dict[str, MetricConfigModel]: + """ + Load a single YAML file that defines metric configs and optional shared sub-configs. + + Expected YAML structure (minimal): + + ```yaml + entity_alignment_configs: + default: + method: label_embedding + verified_entities_path: path/to/entities.csv + entity_sim_threshold: 0.95 + + metrics: + entity_align: + entity_alignment_config_ref: default + + duplicates: + entity_alignment_config_ref: default + + triple_alignment: + reference_kg_path: path/to/reference.nt + entity_alignment_config_ref: default + value_sim_threshold: 0.5 + ``` + + Returned dict keys are metric keys (e.g. "duplicates") and values are instantiated + Pydantic config objects (e.g. `DuplicateConfig`). + """ + path = Path(config_path) + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + if not isinstance(raw, Mapping): + raise TypeError("Top-level YAML must be a mapping/dict.") + + named_entity_alignment: dict[str, dict[str, Any]] = {} + raw_named = raw.get("entity_alignment_configs") or {} + if raw_named: + if not isinstance(raw_named, Mapping): + raise TypeError("`entity_alignment_configs` must be a mapping/dict.") + for k, v in raw_named.items(): + if not isinstance(k, str) or not k: + raise TypeError("`entity_alignment_configs` keys must be non-empty strings.") + if not isinstance(v, Mapping): + raise TypeError(f"`entity_alignment_configs.{k}` must be a mapping/dict.") + named_entity_alignment[k] = dict(v) + + metrics_raw = raw.get("metrics") or {} + if not isinstance(metrics_raw, Mapping): + raise TypeError("`metrics` must be a mapping/dict.") + + out: dict[str, MetricConfigModel] = {} + for metric_key, metric_cfg_any in metrics_raw.items(): + if not isinstance(metric_key, str) or not metric_key: + raise TypeError("Metric keys in `metrics` must be non-empty strings.") + if metric_cfg_any is None: + metric_cfg: dict[str, Any] = {} + elif isinstance(metric_cfg_any, Mapping): + metric_cfg = dict(metric_cfg_any) + else: + raise TypeError(f"`metrics.{metric_key}` must be a mapping/dict.") + + # --- Metric-specific instantiation rules + if metric_key in {"entity_align", "entity_alignment"}: + entity_cfg_dict = _resolve_entity_alignment_config(metric_cfg, named_entity_alignment) + # Allow `reference_kg_path` convenience here too + if "reference_kg_path" in entity_cfg_dict and "reference_kg" not in entity_cfg_dict: + ref_path = Path(entity_cfg_dict.pop("reference_kg_path")) + entity_cfg_dict["reference_kg"] = _kg_from_path(ref_path) + out[metric_key] = EntityAlignmentConfig.model_validate(entity_cfg_dict) + continue + + if metric_key in {"duplicates", "duplicate"}: + entity_cfg_dict = _resolve_entity_alignment_config(metric_cfg, named_entity_alignment) + if "reference_kg_path" in entity_cfg_dict and "reference_kg" not in entity_cfg_dict: + ref_path = Path(entity_cfg_dict.pop("reference_kg_path")) + entity_cfg_dict["reference_kg"] = _kg_from_path(ref_path) + out[metric_key] = DuplicateConfig.model_validate( + { + "entity_alignment_config": EntityAlignmentConfig.model_validate(entity_cfg_dict), + } + ) + continue + + if metric_key in {"triple_alignment", "triple_align"}: + cfg_dict: dict[str, Any] = dict(metric_cfg) + entity_cfg_dict = _resolve_entity_alignment_config(metric_cfg, named_entity_alignment) + if "reference_kg_path" in entity_cfg_dict and "reference_kg" not in entity_cfg_dict: + ref_path = Path(entity_cfg_dict.pop("reference_kg_path")) + entity_cfg_dict["reference_kg"] = _kg_from_path(ref_path) + cfg_dict["entity_alignment_config"] = EntityAlignmentConfig.model_validate(entity_cfg_dict) + + # Allow YAML to specify a path rather than an in-memory KG object + if "reference_kg_path" in cfg_dict and "reference_kg" not in cfg_dict: + ref_path = Path(cfg_dict.pop("reference_kg_path")) + cfg_dict["reference_kg"] = _kg_from_path(ref_path) + + out[metric_key] = TripleAlignmentConfig.model_validate(cfg_dict) + continue + + if metric_key in { + "consistency_violations", + "disjoint_domain", + "domain", + "range", + "relation_direction", + "datatype", + "datatype_format", + }: + cfg_dict = dict(metric_cfg) + if "reference_kg_path" in cfg_dict and "reference_kg" not in cfg_dict: + ref_path = Path(cfg_dict.pop("reference_kg_path")) + cfg_dict["reference_kg"] = _kg_from_path(ref_path) + out[metric_key] = ConsistencyViolationsConfig.model_validate(cfg_dict) + continue + + raise KeyError( + f"Unknown metric key {metric_key!r} in config. " + "Add it to `kgpipe_eval.config.manager.load_metric_configs`." + ) + + return out + diff --git a/src/kgpipe_eval/evaluator.py b/src/kgpipe_eval/evaluator.py new file mode 100644 index 0000000..853a2c6 --- /dev/null +++ b/src/kgpipe_eval/evaluator.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import inspect +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Mapping, Sequence + +from kgpipe_eval.api import Metric, MetricResult +from kgpipe_eval.utils.kg_utils import TripleGraph + + +def _metric_key(metric: Metric) -> str: + return getattr(metric, "key", metric.__class__.__name__) + + +@dataclass +class Evaluator: + """ + Execute multiple metrics against a KG and pass the right config (if any). + """ + + def run( + self, + kg: TripleGraph, + metrics: Sequence[Metric], + confs: Mapping[str, Any] | None = None, + ) -> List[MetricResult]: + confs = dict(confs or {}) + results: List[MetricResult] = [] + + for metric in metrics: + key = _metric_key(metric) + cfg = confs.get(key, confs.get(key.lower())) + + compute = getattr(metric, "compute", None) + if compute is None: + raise TypeError(f"Metric {key!r} has no compute() method") + + sig = inspect.signature(compute) + # Bound method: typically (kg) or (kg, config) + params = [ + p for p in sig.parameters.values() + if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) + ] + + try: + if len(params) <= 1: + # compute(self) or compute(self, kg) -- call without config + res = compute(kg) if len(params) == 1 else compute() + else: + # compute(self, kg, config, ...) + if cfg is None: + raise KeyError( + f"Missing config for metric {key!r}. " + f"Provide `confs[{key!r}]`." + ) + res = compute(kg, cfg) + except Exception as e: + raise RuntimeError(f"Failed running metric {key!r}") from e + + if not isinstance(res, MetricResult): + raise TypeError(f"Metric {key!r} returned {type(res)!r}, expected MetricResult") + results.append(res) + + return results + From 584da5a8c9251cecb01276dc8b5b5924679874c0 Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 8 Apr 2026 16:00:04 +0200 Subject: [PATCH 12/42] feat(eval): tests for refactor --- src/kgpipe_eval/test/examples.py | 172 +++++++++++++++++- src/kgpipe_eval/test/test_alignment_eval.py | 32 ++++ src/kgpipe_eval/test/test_config_manager.py | 52 ++++++ src/kgpipe_eval/test/test_consistency_eval.py | 0 src/kgpipe_eval/test/test_duplicates_eval.py | 20 ++ src/kgpipe_eval/test/test_evaluator.py | 32 ++++ src/kgpipe_eval/test/test_kg_utils.py | 29 +++ src/kgpipe_eval/test/test_statistics_eval.py | 7 +- src/kgpipe_eval/test/utils.py | 88 ++++++++- 9 files changed, 414 insertions(+), 18 deletions(-) create mode 100644 src/kgpipe_eval/test/test_config_manager.py create mode 100644 src/kgpipe_eval/test/test_consistency_eval.py create mode 100644 src/kgpipe_eval/test/test_duplicates_eval.py create mode 100644 src/kgpipe_eval/test/test_evaluator.py create mode 100644 src/kgpipe_eval/test/test_kg_utils.py diff --git a/src/kgpipe_eval/test/examples.py b/src/kgpipe_eval/test/examples.py index 3637490..dc7aeb3 100644 --- a/src/kgpipe_eval/test/examples.py +++ b/src/kgpipe_eval/test/examples.py @@ -1,25 +1,177 @@ +SEED_TURTLE_TRIPLES = """ +@prefix : . +@prefix o: . +@prefix rdf: . +@prefix rdfs: . +@prefix xsd: . + +:store1 rdf:type o:BookStore ; + rdfs:label "Example Books (Downtown)"@en ; + :countryCode "US" ; + :hasInventory :itemA, :itemB, :itemC . + +:publisherHC rdf:type o:Publisher ; + rdfs:label "HarperCollins" ; + :countryCode "GB" . +""" + TEST_TURTLE_TRIPLES = """ -@prefix : . +@prefix : . +@prefix o: . @prefix rdf: . @prefix rdfs: . @prefix xsd: . -:itemA rdf:type :Book ; - rdfs:label "itemA" ; - :bookTitle "The Hobbit, or There and Back Again" ; +# Entities designed to exercise alignment corner-cases: +# - multiple entities per type (Book/Author/Publisher/Store) +# - missing / extra attributes across graphs +# - literal variations (lang tags, datatypes, different lexical forms) +# - ambiguous labels (near-duplicates, casing differences) +# - multi-valued properties + +:store1 rdf:type o:BookStore ; + rdfs:label "Example Books (Downtown)"@en ; + :countryCode "US" ; + :hasInventory :itemA, :itemB, :itemC . + +:publisherHC rdf:type o:Publisher ; + rdfs:label "HarperCollins" ; + :countryCode "GB" . + +# different wrong type +:publisherPenguin rdf:type o:Author ; + rdfs:label "Penguin Books"@en ; + :countryCode "GB" . + +:authorTolkien rdf:type o:Author ; + rdfs:label "J. R. R. Tolkien" ; + :born "1892-01-03"^^xsd:date ; + :died "1973-09-02"^^xsd:date ; + :sameAs . + +:authorRowling rdf:type o:Author ; + rdfs:label "J.K. Rowling" ; + :born "1965-07-31"^^xsd:date . + +:itemA rdf:type o:Book ; + rdfs:label "The Hobbit"@en ; + :bookTitle "The Hobbit, or There and Back Again"@en ; + :bookAuthor :authorTolkien ; + :publisher :publisherHC ; + :isbn13 "9780261102217" ; + :pageCount "310"^^xsd:integer ; + :tags "fantasy", "classic" ; + :inSeries :seriesMiddleEarth . + +:itemB rdf:type o:Book ; + rdfs:label "The Hobbit (Illustrated)"@en ; + :bookTitle "The Hobbit"@en ; :bookAuthor :authorTolkien ; - :isbn13 "9780261102217" . + :publisher :publisherHC ; + :isbn13 "978-0-261-10221-7" ; # lexical variation + :pageCount 320 ; # integer without explicit datatype + :publicationYear "1997"^^xsd:gYear . + +:itemC rdf:type o:Book ; + rdfs:label "Harry Potter and the Philosopher's Stone"@en ; + :bookTitle "Harry Potter and the Philosopher's Stone"@en ; + :bookAuthor :authorRowling ; + :publisher :publisherPenguin ; + :isbn13 "9780747532699" ; + :pageCount "223"^^xsd:integer . + +# Same label, different type (common edge case for label-only alignment) +:hobbit rdf:type o:Film ; + rdfs:label "The Hobbit"@en ; + :releaseYear "2012"^^xsd:gYear . + +# Missing rdf:type but has label (edge case for type-aware matching) +:unknownEntity rdfs:label "HarperCollins" . + +:seriesMiddleEarth rdf:type o:Series ; + rdfs:label "Middle-earth Legendarium"@en . + +# false positive unexpected entity +:unexpectedEntity rdf:type o:Book ; + rdfs:label "Unexpected Entity"@en . """ REFERENCE_TURTLE_TRIPLES = """ -@prefix : . +@prefix : . +@prefix o: . @prefix rdf: . @prefix rdfs: . @prefix xsd: . -:itemA rdf:type :Book ; - rdfs:label "itemA" ; - :bookTitle "The Hobbit, or There and Back Again" ; +# Reference graph intentionally differs from TEST_TURTLE_TRIPLES: +# - different labels / casing / punctuation +# - extra / missing properties +# - alternate modeling (blank nodes, different predicates) +# - near-duplicate entities to test ambiguity + +:storeMain rdf:type o:BookStore ; + rdfs:label "Example Books - Downtown"@en ; + :countryCode "USA" ; # lexical variation + :hasInventory :refItemA, :refItemC . + +:publisherHC rdf:type o:Publisher ; + rdfs:label "Harper Collins"@en ; # spacing difference + :countryCode "UK" . + +:publisherPenguin rdf:type o:Publisher ; + rdfs:label "Penguin"@en ; + :countryCode "GB" . + +:authorTolkien rdf:type o:Author ; + rdfs:label "J.R.R. Tolkien" ; # punctuation difference + :born "1892-01-03"^^xsd:date ; + :sameAs ; + :nameParts [ :given "John" ; :middle "Ronald Reuel" ; :family "Tolkien" ] . + +:authorRowling rdf:type o:Author ; + rdfs:label "Joanne Rowling"@en ; # alias-ish label + :born "1965-07-31"^^xsd:date . + +:refItemA rdf:type o:Book ; + rdfs:label "The Hobbit"@en ; + :title "The Hobbit, or There and Back Again"@en ; # different predicate :bookAuthor :authorTolkien ; - :isbn13 "9780261102217" . + :publisher :publisherHC ; + :isbn13 "9780261102217" ; + :pageCount "310"^^xsd:integer ; + :tags "classic" . # missing one tag compared to test + +# Same-work but modeled as a separate edition entity +:refItemA_Edition1 rdf:type o:Edition ; + rdfs:label "The Hobbit (1st edition)"@en ; + :about :refItemA ; + :publicationYear "1937"^^xsd:gYear . + +:refItemC rdf:type o:Book ; + rdfs:label "Harry Potter and the Philosopher’s Stone"@en ; # curly apostrophe + :bookTitle "Harry Potter and the Philosopher's Stone"@en ; + :bookAuthor :authorRowling ; + :publisher :publisherPenguin ; + :isbn13 "9780747532699" ; + :pageCount "223"^^xsd:integer ; + :tags "fantasy"@en . + +# Near-duplicate label (to trigger ambiguity in label similarity) +:refItemC_US rdf:type o:Book ; + rdfs:label "Harry Potter and the Sorcerer's Stone"@en ; + :sameAs :refItemC . +""" + +VERIFIED_ENTITIES = """ +dataset,entity_id,entity_label,entity_type +test,http://example.org/reference_bookstore/itemA,The Hobbit,o:Book +test,http://example.org/reference_bookstore/itemB,The Hobbit (Illustrated),o:Book +test,http://example.org/reference_bookstore/itemC,Harry Potter and the Philosopher's Stone,o:Book +test,http://example.org/reference_bookstore/authorTolkien,J. R. R. Tolkien,o:Author +test,http://example.org/reference_bookstore/authorRowling,J.K. Rowling,o:Author +test,http://example.org/reference_bookstore/publisherHC,HarperCollins,o:Publisher +test,http://example.org/reference_bookstore/publisherPenguin,Penguin Books,o:Publisher +test,http://example.org/reference_bookstore/store1,Example Books (Downtown),o:BookStore +test,http://example.org/reference_bookstore/seriesMiddleEarth,Middle-earth Legendarium,o:Series +test,http://example.org/reference_bookstore/missingEntity,Gone with the Wind,o:Book """ \ No newline at end of file diff --git a/src/kgpipe_eval/test/test_alignment_eval.py b/src/kgpipe_eval/test/test_alignment_eval.py index e69de29..b8ddc9e 100644 --- a/src/kgpipe_eval/test/test_alignment_eval.py +++ b/src/kgpipe_eval/test/test_alignment_eval.py @@ -0,0 +1,32 @@ +import json + +from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig +from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric +from kgpipe_eval.test.utils import get_test_kg, get_verified_entities_path, render_metric_result +from kgpipe_eval.utils.kg_utils import KgManager +from kgpipe_eval.api import MetricResult + + +def test_align_entities_by_label_embedding(): + config = EntityAlignmentConfig( + method="label_embedding", + reference_kg=None, + verified_entities_path=get_verified_entities_path(), + verified_entities_delimiter=",", + entity_sim_threshold=0.95 + ) + tg = KgManager.load_kg(get_test_kg()) + metric_result : MetricResult = EntityAlignmentMetric().compute(tg, config) + print(render_metric_result(metric_result)) + +def test_align_entities_by_label_embedding_and_type(): + config = EntityAlignmentConfig( + method="label_embedding_and_type", + reference_kg=None, + verified_entities_path=get_verified_entities_path(), + verified_entities_delimiter=",", + entity_sim_threshold=0.95 + ) + tg = KgManager.load_kg(get_test_kg()) + metric_result : MetricResult = EntityAlignmentMetric().compute(tg, config) + print(render_metric_result(metric_result)) \ No newline at end of file diff --git a/src/kgpipe_eval/test/test_config_manager.py b/src/kgpipe_eval/test/test_config_manager.py new file mode 100644 index 0000000..78e8cc5 --- /dev/null +++ b/src/kgpipe_eval/test/test_config_manager.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from pathlib import Path + +from kgpipe_eval.config.manager import load_metric_configs +from kgpipe_eval.metrics.duplicates import DuplicateConfig +from kgpipe_eval.metrics.triple_alignment import TripleAlignmentConfig +from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig + + +def test_load_metric_configs_resolves_entity_alignment_refs(tmp_path: Path) -> None: + cfg = tmp_path / "eval.yaml" + cfg.write_text( + """ +entity_alignment_configs: + default: + method: label_embedding + verified_entities_path: tmp_test_data/verified_entities.csv + verified_entities_delimiter: "," + entity_sim_threshold: 0.95 + +metrics: + entity_align: + entity_alignment_config_ref: default + + duplicates: + entity_alignment_config_ref: default + + triple_alignment: + reference_kg_path: tmp_test_data/reference.nt + entity_alignment_config_ref: default + value_sim_threshold: 0.6 +""".lstrip(), + encoding="utf-8", + ) + + loaded = load_metric_configs(cfg) + assert "entity_align" in loaded + assert "duplicates" in loaded + assert "triple_alignment" in loaded + + assert isinstance(loaded["entity_align"], EntityAlignmentConfig) + assert isinstance(loaded["duplicates"], DuplicateConfig) + assert isinstance(loaded["triple_alignment"], TripleAlignmentConfig) + + assert loaded["entity_align"].verified_entities_delimiter == "," + assert loaded["duplicates"].entity_alignment_config.verified_entities_delimiter == "," + assert loaded["triple_alignment"].entity_alignment_config.verified_entities_delimiter == "," + + # reference_kg is constructed from reference_kg_path + assert loaded["triple_alignment"].reference_kg.path.as_posix().endswith("tmp_test_data/reference.nt") + diff --git a/src/kgpipe_eval/test/test_consistency_eval.py b/src/kgpipe_eval/test/test_consistency_eval.py new file mode 100644 index 0000000..e69de29 diff --git a/src/kgpipe_eval/test/test_duplicates_eval.py b/src/kgpipe_eval/test/test_duplicates_eval.py new file mode 100644 index 0000000..1c21d89 --- /dev/null +++ b/src/kgpipe_eval/test/test_duplicates_eval.py @@ -0,0 +1,20 @@ +from kgpipe_eval.metrics.duplicates import DuplicateConfig +from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig +from kgpipe_eval.test.utils import get_verified_entities_path +from kgpipe_eval.api import MetricResult +from kgpipe_eval.metrics.duplicates import DuplicateMetric +from kgpipe_eval.test.utils import get_test_kg, render_metric_result +from kgpipe_eval.utils.kg_utils import KgManager + +def test_duplicates_eval(): + config = DuplicateConfig( + entity_alignment_config=EntityAlignmentConfig( + method="label_embedding", + reference_kg=None, + verified_entities_path=get_verified_entities_path(), + verified_entities_delimiter=",", + entity_sim_threshold=0.95 + ) + ) + metric_result : MetricResult = DuplicateMetric().compute(KgManager.load_kg(get_test_kg()), config) + print(render_metric_result(metric_result)) diff --git a/src/kgpipe_eval/test/test_evaluator.py b/src/kgpipe_eval/test/test_evaluator.py new file mode 100644 index 0000000..2e2c03b --- /dev/null +++ b/src/kgpipe_eval/test/test_evaluator.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from kgpipe_eval.evaluator import Evaluator +from kgpipe_eval.metrics.statistics import CountMetric +from kgpipe_eval.metrics.duplicates import DuplicateMetric, DuplicateConfig +from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig +from kgpipe_eval.test.utils import get_test_kg, get_verified_entities_path +from kgpipe_eval.utils.kg_utils import KgManager + + +def test_evaluator_runs_metrics_with_and_without_config() -> None: + kg = KgManager.load_kg(get_test_kg()) + try: + dup_cfg = DuplicateConfig( + entity_alignment_config=EntityAlignmentConfig( + method="label_embedding", + verified_entities_path=get_verified_entities_path(), + verified_entities_delimiter=",", + entity_sim_threshold=0.95, + ) + ) + + metrics = [CountMetric(), DuplicateMetric()] + confs = {"DuplicateMetric": dup_cfg} + + results = Evaluator().run(kg=kg, metrics=metrics, confs=confs) + assert len(results) == 2 + assert results[0].metric.__class__.__name__ == "CountMetric" + assert results[1].metric.__class__.__name__ == "DuplicateMetric" + finally: + KgManager.unload_kg(kg) + diff --git a/src/kgpipe_eval/test/test_kg_utils.py b/src/kgpipe_eval/test/test_kg_utils.py new file mode 100644 index 0000000..39f10f0 --- /dev/null +++ b/src/kgpipe_eval/test/test_kg_utils.py @@ -0,0 +1,29 @@ +from kgpipe_eval.utils.kg_utils import KgManager +from kgpipe_eval.test.utils import get_test_kg, get_reference_kg +from pathlib import Path + +tmp_dir = Path("tmp_test_data") + +def test_substract_kg(): + # TODO test can be improved / cleaned up + kg = get_test_kg() + kg_graph = KgManager.load_kg(kg) + kg_path = kg.path + + # read kg + with open(kg_path, "r") as f: + triples = f.readlines() + sample_triples = triples[:10] + other_kg_path = tmp_dir / "other_kg.nt" + with open(other_kg_path, "w") as f: + f.write("\n".join(sample_triples)) + other_kg_graph = KgManager.load_kg(other_kg_path) + + substracted_kg_graph = KgManager.substract_kg(kg_graph, other_kg_graph) + len_kg_triples = len(list(kg_graph.triples((None, None, None)))) + len_other_kg_triples = len(list(other_kg_graph.triples((None, None, None)))) + len_substracted_kg_triples = len(list(substracted_kg_graph.triples((None, None, None)))) + # print(f"len_kg_triples: {len_kg_triples}") + # print(f"len_other_kg_triples: {len_other_kg_triples}") + # print(f"len_substracted_kg_triples: {len_substracted_kg_triples}") + assert len_substracted_kg_triples == len_kg_triples - len_other_kg_triples \ No newline at end of file diff --git a/src/kgpipe_eval/test/test_statistics_eval.py b/src/kgpipe_eval/test/test_statistics_eval.py index 2f1221a..67d803d 100644 --- a/src/kgpipe_eval/test/test_statistics_eval.py +++ b/src/kgpipe_eval/test/test_statistics_eval.py @@ -1,7 +1,8 @@ from kgpipe_eval.metrics.statistics import CountMetric -from kgpipe_eval.test.examples import TEST_TURTLE_TRIPLES, REFERENCE_TURTLE_TRIPLES +from kgpipe_eval.test.utils import get_test_kg +from kgpipe_eval.utils.kg_utils import KgManager def test_count_metric(): metric = CountMetric() - report = metric.compute(TEST_TURTLE_TRIPLES) - render_metric_as_table(report, show_details=SHOW_DETAILS) \ No newline at end of file + report = metric.compute(KgManager.load_kg(get_test_kg())) + print(report) \ No newline at end of file diff --git a/src/kgpipe_eval/test/utils.py b/src/kgpipe_eval/test/utils.py index 0e2b72c..602c752 100644 --- a/src/kgpipe_eval/test/utils.py +++ b/src/kgpipe_eval/test/utils.py @@ -1,11 +1,89 @@ from pathlib import Path from kgpipe.common import KG - +from kgpipe.common.model.data import DataFormat from kgpipe_eval.test.examples import * +from kgpipe_eval.api import MetricResult +from rdflib import Graph +import json +from collections.abc import Mapping, Sequence + +tmp_dir = Path("tmp_test_data") + +if not tmp_dir.exists(): + tmp_dir.mkdir(parents=True, exist_ok=True) + + +def get_test_kg(sample_size: int = -1) -> KG: + test_triples = TEST_TURTLE_TRIPLES + if sample_size > 0: + test_triples = test_triples[:sample_size] + # write test_triples to a file + g = Graph() + g.parse(data=test_triples, format="turtle") + g.serialize(destination=tmp_dir / "test.nt", format="ntriples") + return KG("test", name="test", path=tmp_dir / "test.nt", format=DataFormat.RDF_NTRIPLES) + +def get_reference_kg(sample_size: int = -1) -> KG: + reference_triples = REFERENCE_TURTLE_TRIPLES + if sample_size > 0: + reference_triples = reference_triples[:sample_size] + # write reference_triples to a file + g = Graph() + g.parse(data=reference_triples, format="turtle") + g.serialize(destination=tmp_dir / "reference.nt", format="ntriples") + return KG("reference", name="reference", path=tmp_dir / "reference.nt", format=DataFormat.RDF_NTRIPLES) + +def get_verified_entities_path() -> Path: + path = tmp_dir / "verified_entities.csv" + with open(path, "w") as f: + # Avoid a leading blank line which breaks csv.DictReader header parsing + f.write(VERIFIED_ENTITIES.lstrip().replace("o:", "http://example.org/ontology/")) + return path + +def render_metric_result(metric_result: MetricResult) -> str: + def _metric_key(mr: MetricResult) -> str: + metric = mr.metric + return getattr(metric, "key", metric.__class__.__name__) + + def _fmt_value(v) -> str: + if isinstance(v, float): + # stable, compact representation for test output + return f"{v:.6g}" + if isinstance(v, (int, bool)) or v is None: + return str(v) + if isinstance(v, str): + return v + if isinstance(v, Mapping): + return json.dumps(v, indent=2, sort_keys=True, default=str) + if isinstance(v, Sequence) and not isinstance(v, (str, bytes, bytearray)): + return json.dumps(v, indent=2, sort_keys=True, default=str) + return str(v) + + key = _metric_key(metric_result) + summary = metric_result.summary or "" + + ms = sorted(metric_result.measurements, key=lambda m: m.name) + name_w = max([len("measurement"), *(len(m.name) for m in ms)] or [len("measurement")]) + unit_w = max([len("unit"), *(len(m.unit or "") for m in ms)] or [len("unit")]) + + lines: list[str] = [] + lines.append(f"metric: {key}") + if summary: + lines.append(f"summary: {summary}") + if not ms: + lines.append("(no measurements)") + return "\n".join(lines) -def get_test_kg() -> KG: - return KG() + lines.append("") + lines.append(f"{'measurement':<{name_w}} {'value'}{' ' * max(1, 2)}{'unit':<{unit_w}}") + lines.append(f"{'-' * name_w} {'-' * 20} {'-' * unit_w}") -def get_reference_kg() -> KG: - return KG() + for m in ms: + unit = m.unit or "" + rendered = _fmt_value(m.value) + rendered_lines = rendered.splitlines() or [""] + lines.append(f"{m.name:<{name_w}} {rendered_lines[0]:<20} {unit:<{unit_w}}") + for cont in rendered_lines[1:]: + lines.append(f"{'':<{name_w}} {cont}") + return "\n".join(lines) \ No newline at end of file From 67d964798111cee8006e943e6b35a7066d2708aa Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 8 Apr 2026 16:00:38 +0200 Subject: [PATCH 13/42] feat(eval): commited missing util functions --- src/kgpipe_eval/utils/alignment_utils.py | 98 +++++++++++++++------- src/kgpipe_eval/utils/kg_utils.py | 23 ++++- src/kgpipe_eval/utils/measurement_utils.py | 23 +++-- 3 files changed, 107 insertions(+), 37 deletions(-) diff --git a/src/kgpipe_eval/utils/alignment_utils.py b/src/kgpipe_eval/utils/alignment_utils.py index 8f8d0fa..d5c6adb 100644 --- a/src/kgpipe_eval/utils/alignment_utils.py +++ b/src/kgpipe_eval/utils/alignment_utils.py @@ -1,67 +1,105 @@ from kgpipe.common import KG -from typing import Literal, NamedTuple +from typing import Literal, NamedTuple, Optional from functools import lru_cache -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, model_validator from kgpipe_eval.utils.kg_utils import TripleGraph, Term, Triple from kgpipe.util.embeddings.st_emb import get_model from rdflib import RDFS, RDF +from kgpipe.datasets.multipart_multisource import read_entities_csv, EntitiesRow import numpy as np - -class AlignmentConfig(BaseModel): - model: str = "sentence-transformer" - similarity: str = "cosine" - threshold: float = 0.5 +from pathlib import Path # TODO source entities csv to label only graph -CONFIG=None -# layz config dict -def get_config() -> dict: - global CONFIG - if CONFIG is None: - # TODO - pass - return CONFIG +class EntityAlignmentConfig(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + method: Literal["label_embedding", "label_alias_embedding", "label_embedding_and_type"] = "label_embedding" + reference_kg: Optional[KG] = None + verified_entities_path: Optional[Path] = None + verified_entities_delimiter: str = "\t" + entity_sim_threshold: float = 0.95 + + # value_sim_threshold: float = 0.5 + + @model_validator(mode="after") + def _require_reference_source(self): + if self.reference_kg is None and self.verified_entities_path is None: + raise ValueError("Provide either `reference_kg` or `verified_entities_path`.") + return self + -EntityAlignment = NamedTuple("EntityAlignment", [("source", Term), ("target", Term)]) +EntityAlignment = NamedTuple("EntityAlignment", [("source", Term), ("target", Term), ("score", float)]) TripleAlignment = NamedTuple("TripleAlignment", [("source", Triple), ("target", Triple)]) # Core alignment method interfaces @lru_cache(maxsize=1000) -def get_aligned_entities(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Entity]: +def get_aligned_entities(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[EntityAlignment]: return kg.entities.intersection(reference_kg.entities) -def get_aligned_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[Triple]: +def get_aligned_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzzy", "semantic"] = "exact") -> list[TripleAlignment]: return kg.triples.intersection(reference_kg.triples) # Helper methods -def get_entity_uri_label_pairs(triple_graph: TripleGraph) -> list[tuple[Term, Term]]: - return [(s, label) for s, _, label in triple_graph.triples((None, RDFS.label, None))] +# def get_entity_uri_label_pairs(triple_graph: TripleGraph) -> list[tuple[Term, Term]]: +# return [(s, label) for s, _, label in triple_graph.triples((None, RDFS.label, None))] + +UriLabelTypePair = NamedTuple("UriLabelTypePair", [("uri", Term), ("label", Term), ("type", Term)]) + +def get_entity_uri_label_type_pairs(kg: KG) -> list[UriLabelTypePair]: + label_by_uri = {} + type_by_uri = {} + for s, p, o in kg.triples((None, RDFS.label, None)): + label_by_uri[str(s)] = str(o) + for s, p, o in kg.triples((None, RDF.type, None)): + type_by_uri[str(s)] = str(o) + for uri in label_by_uri: + if uri in type_by_uri: + yield UriLabelTypePair(uri=uri, label=label_by_uri[uri], type=type_by_uri[uri]) + else: + yield UriLabelTypePair(uri=uri, label=label_by_uri[uri], type=None) + +def load_verified_entities(path: Path, delimiter: str = "\t") -> list[UriLabelTypePair]: + """ + """ + if path.name.endswith(".json"): + raise ValueError("JSON format not supported for verified entities") + elif path.name.endswith(".csv"): + return [UriLabelTypePair(uri=entity.entity_id, label=entity.entity_label, type=entity.entity_type) for entity in read_entities_csv(path=path, delimiter=delimiter)] + else: + raise ValueError(f"Unsupported file type: {path}") + +def load_entity_uri_label_type_pairs(config: EntityAlignmentConfig) -> list[UriLabelTypePair]: + if config.verified_entities_path is not None: + return load_verified_entities(config.verified_entities_path, delimiter=config.verified_entities_delimiter) + elif config.reference_kg is not None: + return get_entity_uri_label_type_pairs(config.reference_kg) + else: + raise ValueError("No verified entities path or reference KG provided") # Specific alignment methods -def align_entities_by_label_embedding(triple_graph: TripleGraph, ref_triple_graph: TripleGraph, model="TODO", similarity="cosine", threshold=0.5): +def align_entities_by_label_embedding(tg: TripleGraph, config: EntityAlignmentConfig) -> list[EntityAlignment]: model = get_model() - ref_entity_labels = [str(label) for s, _, label in ref_triple_graph.triples((None, RDFS.label, None))] - ref_entity_labels_embeddings = model.encode(ref_entity_labels, convert_to_numpy=True, show_progress_bar=False) + ref_entity_uri_label_type_pairs = load_entity_uri_label_type_pairs(config) + ref_labels = [pair.label for pair in ref_entity_uri_label_type_pairs] + ref_labels_embeddings = model.encode(ref_labels, convert_to_numpy=True, show_progress_bar=False) - gen_entity_labels = [str(label) for s, _, label in triple_graph.triples((None, RDFS.label, None))] - gen_entity_labels_embeddings = model.encode(gen_entity_labels, convert_to_numpy=True, show_progress_bar=False) + gen_entity_uri_label_type_pairs = list(get_entity_uri_label_type_pairs(tg)) + gen_labels = [pair.label for pair in gen_entity_uri_label_type_pairs] + gen_labels_embeddings = model.encode(gen_labels, convert_to_numpy=True, show_progress_bar=False) - for s, _, label in triple_graph.triples((None, RDFS.label, None)): - gen_entity_labels.append(str(label)) - sims = np.dot(gen_entity_labels_embeddings, ref_entity_labels_embeddings.T) + sims = np.dot(gen_labels_embeddings, ref_labels_embeddings.T) alignments = [] for i in range(sims.shape[0]): best_j = np.argmax(sims[i]) - if sims[i][best_j] >= threshold: - alignments.append(EntityAlignment(source=gen_entity_labels[i], target=ref_entity_labels[best_j], score=sims[i][best_j])) + if sims[i][best_j] >= config.entity_sim_threshold: + alignments.append(EntityAlignment(source=gen_entity_uri_label_type_pairs[i].uri, target=ref_entity_uri_label_type_pairs[best_j].uri, score=sims[i][best_j])) return alignments def align_by_label_alias_embedding(triple_graph: TripleGraph, model="", similarity="cosine", threshold=0.5): diff --git a/src/kgpipe_eval/utils/kg_utils.py b/src/kgpipe_eval/utils/kg_utils.py index 13dbcde..348ad4d 100644 --- a/src/kgpipe_eval/utils/kg_utils.py +++ b/src/kgpipe_eval/utils/kg_utils.py @@ -35,6 +35,9 @@ def triples(self, triple_pattern: TriplePattern) -> Iterable[Triple]: def subjects(self) -> Iterable[Term]: pass + def entities(self) -> Iterable[Term]: + pass + def labels(self, term: Term) -> Literal: pass @@ -116,6 +119,9 @@ def subjects(self) -> Iterable[Term]: g = self._graph() return g.subjects(unique=True) + def entities(self) -> Iterable[Term]: + return self.subjects() # TODO inlcude objects that are not subjects + def labels(self, term: Term) -> Literal: g = self._graph() return g.triples((term, RDFS.label, None)) @@ -131,7 +137,7 @@ class KgManager: """ @staticmethod - def load_kg(kg: KG, backend: Literal["rdflib", "spark"] = "rdflib") -> TripleGraph: + def load_kg(kg: KgLike, backend: Literal["rdflib", "spark"] = "rdflib") -> TripleGraph: if backend == "rdflib": return RdfLibTripleGraph(kg=kg) else: @@ -151,3 +157,18 @@ def cache_kg(kg: TripleGraph) -> None: @staticmethod def unload_kg(kg: TripleGraph) -> None: kg.close() + + + @staticmethod + def substract_kg(kg: TripleGraph, other_kg: TripleGraph) -> TripleGraph: + """ + Substract the other_kg from the kg. + """ + # TODO can be improved later by using a more efficient algorithm + triples = kg._graph().triples((None, None, None)) + other_triples = other_kg._graph() + new_graph = Graph() + for triple in triples: + if triple not in other_triples: + new_graph.add(triple) + return RdfLibTripleGraph(kg=new_graph) \ No newline at end of file diff --git a/src/kgpipe_eval/utils/measurement_utils.py b/src/kgpipe_eval/utils/measurement_utils.py index 8cca7e7..065338b 100644 --- a/src/kgpipe_eval/utils/measurement_utils.py +++ b/src/kgpipe_eval/utils/measurement_utils.py @@ -7,21 +7,32 @@ class BinaryClassificationMeasurement(BaseModel): fn: int def accuracy(self) -> float: - return (self.tp + self.tn) / (self.tp + self.tn + self.fp + self.fn) + denom = (self.tp + self.tn + self.fp + self.fn) + return (self.tp + self.tn) / denom if denom else 0.0 def precision(self) -> float: - return self.tp / (self.tp + self.fp) + denom = (self.tp + self.fp) + return self.tp / denom if denom else 0.0 def recall(self) -> float: - return self.tp / (self.tp + self.fn) + denom = (self.tp + self.fn) + return self.tp / denom if denom else 0.0 def f1_score(self) -> float: - return 2 * self.precision() * self.recall() / (self.precision() + self.recall()) + p = self.precision() + r = self.recall() + denom = (p + r) + return 2 * p * r / denom if denom else 0.0 def __str__(self): return f"tp: {self.tp}, fp: {self.fp}, tn: {self.tn}, fn: {self.fn}, accuracy: {self.accuracy()}, precision: {self.precision()}, recall: {self.recall()}, f1_score: {self.f1_score()}" - def __dict__(self): + def to_dict(self) -> dict: + """ + Convenience export including derived measures. + + Note: do not override BaseModel internals like `__dict__`. + """ return { "tp": self.tp, "fp": self.fp, @@ -30,7 +41,7 @@ def __dict__(self): "accuracy": self.accuracy(), "precision": self.precision(), "recall": self.recall(), - "f1_score": self.f1_score() + "f1_score": self.f1_score(), } BCMeasurement = BinaryClassificationMeasurement \ No newline at end of file From eba506365ac0c89a5e8b70e841b799d2a0157317 Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 9 Apr 2026 23:38:38 +0200 Subject: [PATCH 14/42] exp(moviekg): new eval api implementation --- .../src/moviekg/datasets/tmp_remove_seeds.py | 33 +++++ .../moviekg/evaluation/test_eval_refactor.py | 126 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 experiments/moviekg/src/moviekg/datasets/tmp_remove_seeds.py create mode 100644 experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py diff --git a/experiments/moviekg/src/moviekg/datasets/tmp_remove_seeds.py b/experiments/moviekg/src/moviekg/datasets/tmp_remove_seeds.py new file mode 100644 index 0000000..b29c0b7 --- /dev/null +++ b/experiments/moviekg/src/moviekg/datasets/tmp_remove_seeds.py @@ -0,0 +1,33 @@ +from moviekg.evaluation.test_eval_refactor import KgBenchData + +""" +for every verified_seed remove in the bench data remove the seed entities and store as verified_entities_no_seed.csv +""" + +import pandas as pd +from pathlib import Path + +bench_data = KgBenchData.from_path(Path("/home/marvin/phd/data/moviekg/datasets/film_10k")) + +for i in range(1, 4): + seed = bench_data.dataset.splits[f"split_{0}"].kg_reference.meta.entities.file + current = bench_data.dataset.splits[f"split_{i}"].kg_reference.meta.entities.file + current_path = bench_data.dataset.splits[f"split_{i}"].kg_reference.meta.entities.file + current_new = current_path.with_name(f"{current_path.stem}_no_seed{current_path.suffix}") + + # remove all lines from current that are in seed and save to new file + with open(current, "r") as f: + current_lines = f.readlines() + with open(seed, "r") as f: + seed_lines = f.readlines() + with open(current_new, "w") as f: + if not current_lines: + continue + + # Preserve header (assumes first line is the CSV header) + f.write(current_lines[0]) + + seed_set = set(seed_lines[1:] if seed_lines else []) + for line in current_lines[1:]: + if line not in seed_set: + f.write(line) diff --git a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py new file mode 100644 index 0000000..285bae2 --- /dev/null +++ b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py @@ -0,0 +1,126 @@ +from kgpipe_eval.metrics import CountMetric, DuplicateMetric +from typing import List +from kgpipe_eval.api import MetricConfig, MetricResult +from kgpipe_eval.metrics.statistics import CountMetric +from kgpipe_eval.metrics.duplicates import DuplicateConfig, DuplicateMetric +from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric +from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig +from kgpipe_eval.utils.kg_utils import KgLike, KgManager +from kgpipe_eval.evaluator import Evaluator +from pydantic import BaseModel, ConfigDict + +from kgpipe.datasets.multipart_multisource import Dataset, load_dataset +from kgpipe_eval.test.utils import render_metric_result +from pathlib import Path +import pytest +from kgpipe.common.model.pipeline import KgPipePlan, KgPipeReport +from kgpipe.common.model.kg import KG +from kgpipe.common.model.data import DataFormat +import json +# TODO +# [ ] Dataset Reader (split,ref,source,metadata) +# [ ] Pipeline Results Reader (stage,kg,plan,report,tmp_file) + + +# TODO clearify +# substract seed from kg_1 and kg_1 from kg_2, or only seed from kg_1 and kg_2 + + +EX_BENCH_DATA_PATH = Path("/home/marvin/phd/data/moviekg/datasets/film_10k") +EX_INC_PIPE_DATA_PATH = Path("/home/marvin/phd/data/moviekg/output/large/rdf_a") + +# TODO is a wrapper interface for now, Dataset needs refactor later +# TODO can be abstracted and implemented to have direct method per type, so dict is not needed for access +class KgBenchData(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + dataset: Dataset + + @staticmethod + def from_path(path: Path) -> 'KgBenchData': + dataset = load_dataset(path) + return KgBenchData(dataset=dataset) + + def get_verified_entities_path(self, i: int, source_type: str) -> Path: + current_path = self.dataset.splits[f"split_{i}"].kg_reference.meta.entities.file + current_new = current_path.with_name(f"{current_path.stem}_no_seed{current_path.suffix}") + return current_new + +class KgPipeData(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + result_kg: KgLike # name=rdf_a_1 + plan: KgPipePlan + report: KgPipeReport + tmp_dir: Path + + @staticmethod + def from_path(path: Path | str) -> 'KgPipeData': + path = Path(path) + plan = KgPipePlan.from_path(path / "exec-plan.json") + report = KgPipeReport.from_path(path / "exec-report.json") + tmp_dir = path / "tmp" + return KgPipeData( + result_kg=KG(name=path.name, id=path.name, path=path / "result.nt", format=DataFormat.RDF_NTRIPLES), + plan=plan, + report=report, + tmp_dir=tmp_dir + ) + +def build_config_dict(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> dict[str, MetricConfig]: + dup_cfg = DuplicateConfig( + entity_alignment_config=EntityAlignmentConfig( + method="label_embedding", + verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="todo"), + verified_entities_delimiter="\t", + entity_sim_threshold=0.95, + ) + ) + + ent_cfg = EntityAlignmentConfig( + method="label_embedding_and_type", + verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data + verified_entities_delimiter="\t", + entity_sim_threshold=0.95 + ) + + return { + "DuplicateMetric": dup_cfg, + "EntityAlignmentMetric": ent_cfg, + } + + +def evaluate_stage(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> List[MetricResult]: + tg = KgManager.load_kg(pipe_data.result_kg) + metrics = [ + CountMetric(), + EntityAlignmentMetric(), + DuplicateMetric() + ] + config_dict = build_config_dict(i, pipe_data, bench_data) + return Evaluator().run(tg, metrics, config_dict) + +# EX_PIPE_DATA_PATH = Path("/home/marvin/phd/data/moviekg/output/small/rdf_a/stage_1") +# def test_evaluate_stage(): +# if not EX_PIPE_DATA_PATH.exists() or not EX_BENCH_DATA_PATH.exists(): +# pytest.skip("Local MovieKG eval data not available; test is an integration/WIP scaffold.") +# pipe_data = KgPipeData.from_path(EX_PIPE_DATA_PATH) +# bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) +# results = evaluate_stage(1, pipe_data, bench_data) + +# # render +# print() # avoids pytest output being interleaved with print statements +# for result in results: +# print(render_metric_result(result, truncate=True, truncate_value=3)) + +def test_evaluate_inc_stage(): + if not EX_INC_PIPE_DATA_PATH.exists() or not EX_BENCH_DATA_PATH.exists(): + pytest.skip("Local MovieKG inc data not available; test is an integration/WIP scaffold.") + + for i in range(1, 4): + pipe_data = KgPipeData.from_path(EX_INC_PIPE_DATA_PATH / f"stage_{i}") + bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) + results = evaluate_stage(i, pipe_data, bench_data) + + # render + print() # avoids pytest output being interleaved with print statements + for result in results: + print(render_metric_result(result, truncate=True, truncate_value=3)) \ No newline at end of file From ce1c09d078ef6232e9f1f62c89d5dcb1725c694a Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 10 Apr 2026 15:03:30 +0200 Subject: [PATCH 15/42] feat(eval): finished structure and api of new eval, addded some working metrics --- src/kgpipe_eval/config/manager.py | 128 ++++++++++++- src/kgpipe_eval/evaluator.py | 3 + src/kgpipe_eval/metrics/entity_alignment.py | 14 ++ src/kgpipe_eval/test/test_config_manager.py | 52 +++++- src/kgpipe_eval/test/test_llm_eval.py | 6 +- src/kgpipe_eval/test/test_metric_utils.py | 64 +++++++ src/kgpipe_eval/test/utils.py | 51 +---- src/kgpipe_eval/utils/alignment_utils.py | 7 +- src/kgpipe_eval/utils/kg_utils.py | 8 +- src/kgpipe_eval/utils/metric_utils.py | 197 ++++++++++++++++++++ 10 files changed, 470 insertions(+), 60 deletions(-) create mode 100644 src/kgpipe_eval/test/test_metric_utils.py create mode 100644 src/kgpipe_eval/utils/metric_utils.py diff --git a/src/kgpipe_eval/config/manager.py b/src/kgpipe_eval/config/manager.py index c245681..d7d5d81 100644 --- a/src/kgpipe_eval/config/manager.py +++ b/src/kgpipe_eval/config/manager.py @@ -1,7 +1,8 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Mapping, MutableMapping +from typing import Any, Mapping +import re import yaml from pydantic import BaseModel @@ -16,6 +17,54 @@ MetricConfigModel = BaseModel +REQUIRED = "" + +_VAR_PATTERN = re.compile(r"^\$(\w+)$|^\$\{(\w+)\}$") + + +def _interpolate_vars(obj: Any, vars_map: Mapping[str, Any]) -> Any: + """ + Recursively interpolate simple $var / ${var} references inside YAML-loaded data. + + Only replaces when the *entire* string is a reference token. + """ + if isinstance(obj, str): + m = _VAR_PATTERN.match(obj.strip()) + if not m: + return obj + name = m.group(1) or m.group(2) + if name in vars_map: + return vars_map[name] + return obj + if isinstance(obj, list): + return [_interpolate_vars(v, vars_map) for v in obj] + if isinstance(obj, dict): + return {k: _interpolate_vars(v, vars_map) for k, v in obj.items()} + return obj + + +def _resolve_paths(obj: Any, *, base_dir: Path) -> Any: + """ + Recursively resolve relative paths for common config keys. + + - For keys ending with `_path` or `_kg_path`, if the value is a str/Path and + relative, make it absolute by joining with `base_dir`. + - For `reference_kg` when passed as str/Path, treat it as a path too. + """ + if isinstance(obj, list): + return [_resolve_paths(v, base_dir=base_dir) for v in obj] + if isinstance(obj, dict): + out: dict[str, Any] = {} + for k, v in obj.items(): + vv = _resolve_paths(v, base_dir=base_dir) + if isinstance(vv, (str, Path)): + if k == "reference_kg" or k.endswith("_path") or k.endswith("_kg_path"): + p = Path(vv) + if not p.is_absolute(): + vv = (base_dir / p).resolve() + out[k] = vv + return out + return obj def _deep_merge_dict(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]: @@ -118,6 +167,13 @@ def load_metric_configs(config_path: str | Path) -> dict[str, MetricConfigModel] if not isinstance(raw, Mapping): raise TypeError("Top-level YAML must be a mapping/dict.") + # Allow simple variable indirection like: + # reference_kg: test.ttl + # ... reference_kg: $reference_kg + vars_map = {k: v for k, v in raw.items() if isinstance(k, str)} + raw = _interpolate_vars(raw, vars_map) + raw = _resolve_paths(raw, base_dir=path.parent) + named_entity_alignment: dict[str, dict[str, Any]] = {} raw_named = raw.get("entity_alignment_configs") or {} if raw_named: @@ -152,6 +208,9 @@ def load_metric_configs(config_path: str | Path) -> dict[str, MetricConfigModel] if "reference_kg_path" in entity_cfg_dict and "reference_kg" not in entity_cfg_dict: ref_path = Path(entity_cfg_dict.pop("reference_kg_path")) entity_cfg_dict["reference_kg"] = _kg_from_path(ref_path) + # Backward compatible: accept `reference_kg: "/path/to/file.nt"` in YAML + if isinstance(entity_cfg_dict.get("reference_kg"), (str, Path)): + entity_cfg_dict["reference_kg"] = _kg_from_path(Path(entity_cfg_dict["reference_kg"])) out[metric_key] = EntityAlignmentConfig.model_validate(entity_cfg_dict) continue @@ -160,6 +219,8 @@ def load_metric_configs(config_path: str | Path) -> dict[str, MetricConfigModel] if "reference_kg_path" in entity_cfg_dict and "reference_kg" not in entity_cfg_dict: ref_path = Path(entity_cfg_dict.pop("reference_kg_path")) entity_cfg_dict["reference_kg"] = _kg_from_path(ref_path) + if isinstance(entity_cfg_dict.get("reference_kg"), (str, Path)): + entity_cfg_dict["reference_kg"] = _kg_from_path(Path(entity_cfg_dict["reference_kg"])) out[metric_key] = DuplicateConfig.model_validate( { "entity_alignment_config": EntityAlignmentConfig.model_validate(entity_cfg_dict), @@ -173,6 +234,8 @@ def load_metric_configs(config_path: str | Path) -> dict[str, MetricConfigModel] if "reference_kg_path" in entity_cfg_dict and "reference_kg" not in entity_cfg_dict: ref_path = Path(entity_cfg_dict.pop("reference_kg_path")) entity_cfg_dict["reference_kg"] = _kg_from_path(ref_path) + if isinstance(entity_cfg_dict.get("reference_kg"), (str, Path)): + entity_cfg_dict["reference_kg"] = _kg_from_path(Path(entity_cfg_dict["reference_kg"])) cfg_dict["entity_alignment_config"] = EntityAlignmentConfig.model_validate(entity_cfg_dict) # Allow YAML to specify a path rather than an in-memory KG object @@ -196,6 +259,8 @@ def load_metric_configs(config_path: str | Path) -> dict[str, MetricConfigModel] if "reference_kg_path" in cfg_dict and "reference_kg" not in cfg_dict: ref_path = Path(cfg_dict.pop("reference_kg_path")) cfg_dict["reference_kg"] = _kg_from_path(ref_path) + if isinstance(cfg_dict.get("reference_kg"), (str, Path)): + cfg_dict["reference_kg"] = _kg_from_path(Path(cfg_dict["reference_kg"])) out[metric_key] = ConsistencyViolationsConfig.model_validate(cfg_dict) continue @@ -206,3 +271,64 @@ def load_metric_configs(config_path: str | Path) -> dict[str, MetricConfigModel] return out + +def generate_default_config_dict() -> dict[str, Any]: + """ + Generate a complete default YAML config structure for all supported metric configs. + + This is intended as a *template* for users. Required values are filled with the + placeholder string `""`. + """ + # Shared sub-config defaults + entity_alignment_default = { + "method": "label_embedding", + # Prefer a path-based template: avoids embedding runtime `KG` objects into YAML. + "verified_entities_path": REQUIRED, + "verified_entities_delimiter": EntityAlignmentConfig.model_fields["verified_entities_delimiter"].default, + "entity_sim_threshold": EntityAlignmentConfig.model_fields["entity_sim_threshold"].default, + } + + return { + "entity_alignment_configs": { + "default": entity_alignment_default, + }, + "metrics": { + # Standalone metric uses EntityAlignmentConfig directly via a ref. + "entity_align": { + "entity_alignment_config_ref": "default", + }, + "duplicates": { + "entity_alignment_config_ref": "default", + }, + "triple_alignment": { + "reference_kg_path": REQUIRED, + "entity_alignment_config_ref": "default", + "value_sim_threshold": TripleAlignmentConfig.model_fields["value_sim_threshold"].default, + }, + # Consistency config currently requires both fields at type-level; + # template includes both so users can fill in one/both. + "consistency_violations": { + "reference_kg_path": REQUIRED, + "ontology_path": REQUIRED, + }, + }, + } + + +def generate_default_config_yaml() -> str: + """ + Return a YAML string (template) for `load_metric_configs`. + """ + cfg = generate_default_config_dict() + # Keep output stable and readable. + return yaml.safe_dump(cfg, sort_keys=False, default_flow_style=False) + + +def write_default_config_yaml(path: str | Path) -> Path: + """ + Write a default template YAML to disk and return the written path. + """ + out_path = Path(path) + out_path.write_text(generate_default_config_yaml(), encoding="utf-8") + return out_path + diff --git a/src/kgpipe_eval/evaluator.py b/src/kgpipe_eval/evaluator.py index 853a2c6..ec2f045 100644 --- a/src/kgpipe_eval/evaluator.py +++ b/src/kgpipe_eval/evaluator.py @@ -3,6 +3,7 @@ import inspect from dataclasses import dataclass from typing import Any, Dict, Iterable, List, Mapping, Sequence +import traceback from kgpipe_eval.api import Metric, MetricResult from kgpipe_eval.utils.kg_utils import TripleGraph @@ -55,6 +56,8 @@ def run( ) res = compute(kg, cfg) except Exception as e: + print(f"Failed running metric {key!r}: {e}") + print(traceback.format_exc()) raise RuntimeError(f"Failed running metric {key!r}") from e if not isinstance(res, MetricResult): diff --git a/src/kgpipe_eval/metrics/entity_alignment.py b/src/kgpipe_eval/metrics/entity_alignment.py index ae68f40..3d9c0b6 100644 --- a/src/kgpipe_eval/metrics/entity_alignment.py +++ b/src/kgpipe_eval/metrics/entity_alignment.py @@ -26,6 +26,20 @@ def eval_entity_alignment_by_label_embedding_and_type(kg: KG, config: EntityAlig ref_entity_uri_label_type_pairs = load_entity_uri_label_type_pairs(config) gen_entity_uri_label_type_pairs = list(get_entity_uri_label_type_pairs(kg)) + # print ref and gen pairs for testing + # print("--------------------------------") + # print("ref_entity_uri_label_type_pairs") + # for pair in ref_entity_uri_label_type_pairs: + # print(pair) + # print("--------------------------------") + # print("gen_entity_uri_label_type_pairs") + # for pair in gen_entity_uri_label_type_pairs: + # print(pair) + # print("--------------------------------") + # print("alignments") + # for alignment in alignments: + # print(alignment) + ref_types = {pair.uri: pair.type for pair in ref_entity_uri_label_type_pairs if pair.type is not None} # TODO gen_types can be multiple types, we need to handle this gen_types = {pair.uri: pair.type for pair in gen_entity_uri_label_type_pairs if pair.type is not None} diff --git a/src/kgpipe_eval/test/test_config_manager.py b/src/kgpipe_eval/test/test_config_manager.py index 78e8cc5..37af30d 100644 --- a/src/kgpipe_eval/test/test_config_manager.py +++ b/src/kgpipe_eval/test/test_config_manager.py @@ -2,7 +2,7 @@ from pathlib import Path -from kgpipe_eval.config.manager import load_metric_configs +from kgpipe_eval.config.manager import load_metric_configs, generate_default_config_dict from kgpipe_eval.metrics.duplicates import DuplicateConfig from kgpipe_eval.metrics.triple_alignment import TripleAlignmentConfig from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig @@ -50,3 +50,53 @@ def test_load_metric_configs_resolves_entity_alignment_refs(tmp_path: Path) -> N # reference_kg is constructed from reference_kg_path assert loaded["triple_alignment"].reference_kg.path.as_posix().endswith("tmp_test_data/reference.nt") + +def test_generate_default_config_dict_has_all_sections() -> None: + cfg = generate_default_config_dict() + assert "entity_alignment_configs" in cfg + assert "metrics" in cfg + assert "default" in cfg["entity_alignment_configs"] + assert "verified_entities_path" in cfg["entity_alignment_configs"]["default"] + + metrics = cfg["metrics"] + assert "entity_align" in metrics + assert "duplicates" in metrics + assert "triple_alignment" in metrics + assert "consistency_violations" in metrics + + +def test_load_metric_configs_interpolates_vars_and_resolves_paths(tmp_path: Path) -> None: + # mirror the style used in experiments/examples/scripts/run_eval.yaml + cfg = tmp_path / "run_eval.yaml" + (tmp_path / "test.ttl").write_text( + """ +@prefix : . +@prefix rdfs: . +:a rdfs:label "A" . +""".lstrip(), + encoding="utf-8", + ) + cfg.write_text( + """ +reference_kg: test.ttl + +entity_alignment_configs: + default: + method: label_embedding + reference_kg: $reference_kg + entity_sim_threshold: 0.95 + +metrics: + duplicates: + entity_alignment_config_ref: default +""".lstrip(), + encoding="utf-8", + ) + + loaded = load_metric_configs(cfg) + assert isinstance(loaded["duplicates"], DuplicateConfig) + # reference_kg should be a KG whose path resolves relative to cfg location + kg = loaded["duplicates"].entity_alignment_config.reference_kg + assert kg is not None + assert kg.path == (tmp_path / "test.ttl").resolve() + diff --git a/src/kgpipe_eval/test/test_llm_eval.py b/src/kgpipe_eval/test/test_llm_eval.py index c438e71..b02ced2 100644 --- a/src/kgpipe_eval/test/test_llm_eval.py +++ b/src/kgpipe_eval/test/test_llm_eval.py @@ -1,6 +1,6 @@ import pytest -@pytest.skip(reason="Long running test") -def test_llm_eval(): - pass +# @pytest.skip(reason="Long running test") +# def test_llm_eval(): +# pass diff --git a/src/kgpipe_eval/test/test_metric_utils.py b/src/kgpipe_eval/test/test_metric_utils.py new file mode 100644 index 0000000..6ddc8c8 --- /dev/null +++ b/src/kgpipe_eval/test/test_metric_utils.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from kgpipe_eval.utils.metric_utils import eval_results_jsons_to_rows, write_eval_csv + + +def test_eval_results_json_to_rows_and_csv(tmp_path: Path) -> None: + # Create a fake output structure: //stage_1/eval_results.json + p = tmp_path / "rdf_a" / "stage_1" + p.mkdir(parents=True) + + (p / "eval_results.json").write_text( + json.dumps( + [ + { + "metric": "DuplicateMetric", + "summary": "Duplicates in the KG", + "measurements": [ + {"name": "duplicates", "value": 3, "unit": "number"}, + {"name": "entity_count", "value": 10, "unit": "number"}, + {"name": "duplicates_ratio", "value": 0.3, "unit": "percentage"}, + ], + } + ] + ) + ) + + allowlist = { + "DuplicateMetric": { + "duplicates": "number", + "entity_count": "number", + "duplicates_ratio": "percentage", + } + } + + rows = eval_results_jsons_to_rows([p / "eval_results.json"], allowlist=allowlist) + assert rows == [ + { + "pipeline": "rdf_a", + "stage": "stage_1", + "DuplicateMetric__duplicates__number": 3, + "DuplicateMetric__entity_count__number": 10, + "DuplicateMetric__duplicates_ratio__percentage": 0.3, + } + ] + + out_csv = tmp_path / "out.csv" + write_eval_csv([p / "eval_results.json"], out_path=out_csv, allowlist=allowlist) + txt = out_csv.read_text() + + # Header + one row, with stable columns including pipeline/stage and allowlist columns. + lines = [l for l in txt.splitlines() if l.strip()] + assert len(lines) == 2 + assert lines[0].split(",") == [ + "pipeline", + "stage", + "DuplicateMetric__duplicates__number", + "DuplicateMetric__duplicates_ratio__percentage", + "DuplicateMetric__entity_count__number", + ] + assert lines[1].split(",") == ["rdf_a", "stage_1", "3", "0.3", "10"] + diff --git a/src/kgpipe_eval/test/utils.py b/src/kgpipe_eval/test/utils.py index 602c752..afe8034 100644 --- a/src/kgpipe_eval/test/utils.py +++ b/src/kgpipe_eval/test/utils.py @@ -3,6 +3,7 @@ from kgpipe.common.model.data import DataFormat from kgpipe_eval.test.examples import * from kgpipe_eval.api import MetricResult +from kgpipe_eval.utils.metric_utils import render_metric_result from rdflib import Graph import json from collections.abc import Mapping, Sequence @@ -38,52 +39,4 @@ def get_verified_entities_path() -> Path: with open(path, "w") as f: # Avoid a leading blank line which breaks csv.DictReader header parsing f.write(VERIFIED_ENTITIES.lstrip().replace("o:", "http://example.org/ontology/")) - return path - -def render_metric_result(metric_result: MetricResult) -> str: - def _metric_key(mr: MetricResult) -> str: - metric = mr.metric - return getattr(metric, "key", metric.__class__.__name__) - - def _fmt_value(v) -> str: - if isinstance(v, float): - # stable, compact representation for test output - return f"{v:.6g}" - if isinstance(v, (int, bool)) or v is None: - return str(v) - if isinstance(v, str): - return v - if isinstance(v, Mapping): - return json.dumps(v, indent=2, sort_keys=True, default=str) - if isinstance(v, Sequence) and not isinstance(v, (str, bytes, bytearray)): - return json.dumps(v, indent=2, sort_keys=True, default=str) - return str(v) - - key = _metric_key(metric_result) - summary = metric_result.summary or "" - - ms = sorted(metric_result.measurements, key=lambda m: m.name) - name_w = max([len("measurement"), *(len(m.name) for m in ms)] or [len("measurement")]) - unit_w = max([len("unit"), *(len(m.unit or "") for m in ms)] or [len("unit")]) - - lines: list[str] = [] - lines.append(f"metric: {key}") - if summary: - lines.append(f"summary: {summary}") - if not ms: - lines.append("(no measurements)") - return "\n".join(lines) - - lines.append("") - lines.append(f"{'measurement':<{name_w}} {'value'}{' ' * max(1, 2)}{'unit':<{unit_w}}") - lines.append(f"{'-' * name_w} {'-' * 20} {'-' * unit_w}") - - for m in ms: - unit = m.unit or "" - rendered = _fmt_value(m.value) - rendered_lines = rendered.splitlines() or [""] - lines.append(f"{m.name:<{name_w}} {rendered_lines[0]:<20} {unit:<{unit_w}}") - for cont in rendered_lines[1:]: - lines.append(f"{'':<{name_w}} {cont}") - - return "\n".join(lines) \ No newline at end of file + return path \ No newline at end of file diff --git a/src/kgpipe_eval/utils/alignment_utils.py b/src/kgpipe_eval/utils/alignment_utils.py index d5c6adb..80546a4 100644 --- a/src/kgpipe_eval/utils/alignment_utils.py +++ b/src/kgpipe_eval/utils/alignment_utils.py @@ -3,7 +3,7 @@ from functools import lru_cache from pydantic import BaseModel, ConfigDict, model_validator -from kgpipe_eval.utils.kg_utils import TripleGraph, Term, Triple +from kgpipe_eval.utils.kg_utils import TripleGraph, Term, Triple, KgLike, KgManager from kgpipe.util.embeddings.st_emb import get_model from rdflib import RDFS, RDF @@ -16,7 +16,7 @@ class EntityAlignmentConfig(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) method: Literal["label_embedding", "label_alias_embedding", "label_embedding_and_type"] = "label_embedding" - reference_kg: Optional[KG] = None + reference_kg: Optional[KgLike] = None verified_entities_path: Optional[Path] = None verified_entities_delimiter: str = "\t" entity_sim_threshold: float = 0.95 @@ -76,7 +76,8 @@ def load_entity_uri_label_type_pairs(config: EntityAlignmentConfig) -> list[UriL if config.verified_entities_path is not None: return load_verified_entities(config.verified_entities_path, delimiter=config.verified_entities_delimiter) elif config.reference_kg is not None: - return get_entity_uri_label_type_pairs(config.reference_kg) + # `get_entity_uri_label_type_pairs` is a generator; downstream alignment uses indexing. + return list(get_entity_uri_label_type_pairs(KgManager.load_kg(config.reference_kg))) else: raise ValueError("No verified entities path or reference KG provided") diff --git a/src/kgpipe_eval/utils/kg_utils.py b/src/kgpipe_eval/utils/kg_utils.py index 348ad4d..09b3410 100644 --- a/src/kgpipe_eval/utils/kg_utils.py +++ b/src/kgpipe_eval/utils/kg_utils.py @@ -105,10 +105,12 @@ class RdfLibTripleGraph(TripleGraph): def _graph(self) -> Graph: if isinstance(self.kg, Graph): return self.kg - if isinstance(self.kg, KG): + elif isinstance(self.kg, KG): return self.kg.get_graph() - # Assume filesystem path - return Graph().parse(str(self.kg)) + elif isinstance(self.kg, Path): + return Graph().parse(str(self.kg)) + else: + raise ValueError(f"Unsupported KG type: {type(self.kg)}") def triples(self, triple_pattern: TriplePattern) -> Iterable[Triple]: g = self._graph() diff --git a/src/kgpipe_eval/utils/metric_utils.py b/src/kgpipe_eval/utils/metric_utils.py new file mode 100644 index 0000000..71e0c71 --- /dev/null +++ b/src/kgpipe_eval/utils/metric_utils.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import csv +import json +from dataclasses import dataclass +from pathlib import Path +from collections.abc import Mapping, Sequence +from typing import Any, Iterable + +JsonValue = Any + + +@dataclass(frozen=True) +class MeasurementKey: + metric: str + measurement: str + unit: str + + +Allowlist = Mapping[str, Mapping[str, str]] + +from kgpipe_eval.api import MetricResult + + +def render_metric_result(metric_result: MetricResult, truncate: bool = False, truncate_value: int = 5) -> str: + """ + Render a MetricResult into a human-readable table-like string. + + This is intended for CLI/test output (not machine-parseable export). + """ + + def _metric_key(mr: MetricResult) -> str: + metric = mr.metric + return getattr(metric, "key", metric.__class__.__name__) + + def _fmt_value(v: Any) -> str: + if isinstance(v, float): + # stable, compact representation for test output + return f"{v:.6g}" + if isinstance(v, (int, bool)) or v is None: + return str(v) + if isinstance(v, str): + if truncate: + lines = v.splitlines()[:truncate_value] + return "\n".join(lines) + "\n..." + return v + if isinstance(v, Mapping): + rendered = json.dumps(v, indent=2, sort_keys=True, default=str) + if truncate: + return "\n".join(rendered.splitlines()[:truncate_value]) + "\n..." + return rendered + if isinstance(v, Sequence) and not isinstance(v, (str, bytes, bytearray)): + rendered = json.dumps(v, indent=2, sort_keys=True, default=str) + if truncate: + return "\n".join(rendered.splitlines()[:truncate_value]) + "\n..." + return rendered + return str(v) + + key = _metric_key(metric_result) + summary = metric_result.summary or "" + + ms = sorted(metric_result.measurements, key=lambda m: m.name) + name_w = max([len("measurement"), *(len(m.name) for m in ms)] or [len("measurement")]) + unit_w = max([len("unit"), *(len(m.unit or "") for m in ms)] or [len("unit")]) + + lines: list[str] = [] + lines.append("=" * 80) + lines.append(f"metric: {key}") + if summary: + lines.append(f"summary: {summary}") + if not ms: + lines.append("(no measurements)") + return "\n".join(lines) + + lines.append("") + lines.append(f"{'measurement':<{name_w}} {'value'}{' ' * max(1, 2)}{'unit':<{unit_w}}") + lines.append(f"{'-' * name_w} {'-' * 20} {'-' * unit_w}") + + for m in ms: + unit = m.unit or "" + rendered = _fmt_value(m.value) + rendered_lines = rendered.splitlines() or [""] + lines.append(f"{m.name:<{name_w}} {rendered_lines[0]:<20} {unit:<{unit_w}}") + for cont in rendered_lines[1:]: + lines.append(f"{'':<{name_w}} {cont}") + + return "\n".join(lines) + + +def parse_eval_results(path: Path) -> dict[MeasurementKey, JsonValue]: + """ + Parse a single `eval_results.json` and return a flattened mapping. + + Expected file schema (per entry): + - metric: str + - measurements: [{name: str, value: any-json, unit: str|null}, ...] + """ + raw = json.loads(path.read_text()) + if not isinstance(raw, list): + raise ValueError(f"{path} must contain a JSON list, got {type(raw).__name__}") + + out: dict[MeasurementKey, JsonValue] = {} + for entry in raw: + if not isinstance(entry, Mapping): + raise ValueError(f"{path} entries must be objects, got {type(entry).__name__}") + + metric = entry.get("metric") + if not isinstance(metric, str) or not metric: + raise ValueError(f"{path} entry missing 'metric' string") + + measurements = entry.get("measurements", []) + if not isinstance(measurements, list): + raise ValueError(f"{path} entry 'measurements' must be a list") + + for m in measurements: + if not isinstance(m, Mapping): + continue + name = m.get("name") + unit = m.get("unit") + if not isinstance(name, str) or not name: + continue + if unit is None: + unit = "" + if not isinstance(unit, str): + unit = str(unit) + out[MeasurementKey(metric=metric, measurement=name, unit=unit)] = m.get("value") + + return out + + +def allowlist_to_columns(allowlist: Allowlist) -> list[str]: + cols: list[str] = [] + for metric in sorted(allowlist.keys()): + for measurement in sorted(allowlist[metric].keys()): + unit = allowlist[metric][measurement] + cols.append(f"{metric}__{measurement}__{unit}") + return cols + + +def eval_results_jsons_to_rows( + paths: Sequence[Path], + *, + allowlist: Allowlist, +) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + + for path in paths: + if path.name != "eval_results.json": + raise ValueError(f"Expected eval_results.json file, got {path}") + stage_dir = path.parent + stage = stage_dir.name + if not stage.startswith("stage_"): + raise ValueError(f"Expected stage directory named stage_*, got {stage_dir}") + + pipeline_dir = stage_dir.parent + pipeline = pipeline_dir.name + if not pipeline: + raise ValueError(f"Could not derive pipeline name from {path}") + + flat = parse_eval_results(path) + + row: dict[str, Any] = {"pipeline": pipeline, "stage": stage} + for metric, measurements in allowlist.items(): + for measurement, unit in measurements.items(): + key = MeasurementKey(metric=metric, measurement=measurement, unit=unit) + col = f"{metric}__{measurement}__{unit}" + row[col] = flat.get(key, "") + + rows.append(row) + + return rows + + +def write_eval_csv( + paths: Sequence[Path], + *, + out_path: Path, + allowlist: Allowlist, + delimiter: str = ",", + round_ndigits: int | None = None, +) -> None: + rows = eval_results_jsons_to_rows(paths, allowlist=allowlist) + columns = ["pipeline", "stage", *allowlist_to_columns(allowlist)] + + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=columns, extrasaction="ignore", delimiter=delimiter) + writer.writeheader() + for r in rows: + # Ensure blanks for missing keys + row = {k: r.get(k, "") for k in columns} + if round_ndigits is not None: + for k, v in list(row.items()): + if isinstance(v, float): + row[k] = round(v, round_ndigits) + writer.writerow(row) + From a223d83a0093b4ec204e03f37047f1e354b7a5bd Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 10 Apr 2026 15:04:12 +0200 Subject: [PATCH 16/42] feat(eval): changes to core, for new eval --- src/kgpipe/cli/eval_new.py | 194 +++++++++++++++++-- src/kgpipe/cli/main.py | 2 + src/kgpipe/common/model/pipeline.py | 15 +- src/kgpipe/datasets/multipart_multisource.py | 4 +- 4 files changed, 198 insertions(+), 17 deletions(-) diff --git a/src/kgpipe/cli/eval_new.py b/src/kgpipe/cli/eval_new.py index a11eb17..3c9b442 100644 --- a/src/kgpipe/cli/eval_new.py +++ b/src/kgpipe/cli/eval_new.py @@ -4,12 +4,14 @@ from typing import List, Optional, Sequence, Any import json from pathlib import Path +import codecs from kgpipe_eval.metrics.statistics import CountMetric from kgpipe_eval.metrics.duplicates import DuplicateMetric from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric from kgpipe_eval.utils.kg_utils import KgManager -from kgpipe_eval.config.manager import load_metric_configs +from kgpipe_eval.utils.metric_utils import MeasurementKey, parse_eval_results, write_eval_csv +from kgpipe_eval.config.manager import load_metric_configs, write_default_config_yaml from kgpipe_eval.evaluator import Evaluator # from kgpipe_eval.metrics.semantic import OntologyClassCoverageMetric, OntologyRelationCoverageMetric, OntologyNamespaceCoverageMetric # from kgpipe_eval.metrics.reference import PrecisionMetric, RecallMetric, F1ScoreMetric @@ -20,6 +22,45 @@ console = Console() +_DEFAULT_EVAL_RESULTS_ALLOWLIST = { + "DuplicateMetric": { + "duplicates": "number", + "entity_count": "number", + "duplicates_ratio": "percentage", + } +} + +def _measurement_key_to_col(k: MeasurementKey) -> str: + return f"{k.metric}__{k.measurement}__{k.unit}" + + +def _col_to_measurement_key(col: str) -> MeasurementKey: + parts = col.split("__") + if len(parts) != 3 or not all(parts): + raise click.ClickException( + f"Invalid selection '{col}'. Expected format: ____" + ) + return MeasurementKey(metric=parts[0], measurement=parts[1], unit=parts[2]) + + +def _available_eval_result_keys(paths: list[Path]) -> list[MeasurementKey]: + keys: set[MeasurementKey] = set() + for p in paths: + flat = parse_eval_results(p) + keys.update(flat.keys()) + return sorted(keys, key=_measurement_key_to_col) + +def _decode_single_char_delimiter(delimiter: str) -> str: + """ + Allow passing common escape sequences like '\\t' for tab. + """ + decoded = codecs.decode(delimiter, "unicode_escape") if "\\" in delimiter else delimiter + if len(decoded) != 1: + raise click.ClickException( + f"--delimiter must be a single character (you passed {delimiter!r} -> {decoded!r})" + ) + return decoded + def _available_metric_instances() -> dict[str, Any]: # Keep this explicit until the metrics package is more complete/stable. @@ -61,13 +102,20 @@ def _build_confs_for_selected_metrics( norm_mkey = _normalize_key(mkey) norm_cls = _normalize_key(metric.__class__.__name__) + # Try common YAML ids derived from metric names + base_from_key = norm_mkey.replace("_metric", "").replace("metric", "") + base_from_cls = norm_cls.replace("_metric", "").replace("metric", "") + cfg = ( confs_by_norm.get(norm_mkey) or confs_by_norm.get(norm_cls) or confs_by_norm.get(_normalize_key(alias_to_metric_key.get(norm_mkey, ""))) or confs_by_norm.get(_normalize_key(alias_to_metric_key.get(norm_cls, ""))) - or confs_by_norm.get(norm_mkey.replace("metric", "")) - or confs_by_norm.get(norm_cls.replace("metric", "")) + or confs_by_norm.get(base_from_key) + or confs_by_norm.get(base_from_cls) + # plural fallback (e.g. DuplicateMetric -> duplicates) + or confs_by_norm.get(f"{base_from_key}s") + or confs_by_norm.get(f"{base_from_cls}s") ) if cfg is not None: @@ -112,20 +160,27 @@ def _results_to_json_rows(kg_path: str, metric_key: str, measurements: Sequence[ return rows -@click.command() +@click.group(name="eval-new") +def eval_new_cmd() -> None: + """ + Evaluation commands for the new metric framework. + """ + + +@eval_new_cmd.command(name="run") @click.argument("kg_paths", nargs=-1, type=click.Path(exists=True)) @click.option( - "--config", - "-c", - type=click.Path(exists=True), - help="Path to metric config file" + "--config", + "-c", + type=click.Path(exists=True), + help="Path to metric config file", ) @click.option( - "--metrics", - "-m", - multiple=True, + "--metrics", + "-m", + multiple=True, type=click.Choice(sorted(_available_metric_instances().keys())), - help="Metrics to compute" + help="Metrics to compute", ) @click.option( "--output", @@ -134,7 +189,7 @@ def _results_to_json_rows(kg_path: str, metric_key: str, measurements: Sequence[ help="Write results to a JSON file (list of measurement rows).", ) @click.pass_context -def eval_new_cmd(ctx: click.Context, kg_paths: List[str], config: Optional[str], metrics: tuple, output: Optional[str]): +def run_cmd(ctx: click.Context, kg_paths: List[str], config: Optional[str], metrics: tuple, output: Optional[str]) -> None: """ Compute selected metrics for one or more KGs. @@ -172,4 +227,115 @@ def eval_new_cmd(ctx: click.Context, kg_paths: List[str], config: Optional[str], out_path = Path(output) out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(json.dumps(all_rows, indent=2, ensure_ascii=False, default=str) + "\n", encoding="utf-8") - console.print(f"[green]✓ Saved results to[/green] {output}") \ No newline at end of file + console.print(f"[green]✓ Saved results to[/green] {output}") + + +@eval_new_cmd.command(name="init-config") +@click.argument("output_path", type=click.Path(dir_okay=False), default="eval.default.yaml", required=False) +def init_config_cmd(output_path: str) -> None: + """ + Write a default metric-config template YAML to OUTPUT_PATH. + """ + out = write_default_config_yaml(output_path) + console.print(f"[green]✓ Wrote default config to[/green] {out}") + + +@eval_new_cmd.command(name="to-csv") +@click.argument("eval_json_paths", nargs=-1, type=click.Path(exists=True, dir_okay=False)) +@click.option( + "--glob", + "glob_pattern", + type=str, + help="Optional glob pattern (expanded by the shell) for eval_results.json files.", +) +@click.option( + "--select", + "-s", + "selected_cols", + multiple=True, + help="Select columns to include (repeatable). Format: ____. If omitted, defaults are used.", +) +@click.option( + "--list-keys", + is_flag=True, + help="Print available column keys found in the inputs and exit.", +) +@click.option( + "--round", + "round_ndigits", + type=int, + default=None, + help="Round float values to N decimal digits before writing CSV.", +) +@click.option( + "--delimiter", + "delimiter", + type=str, + default=",", + show_default=True, + help="CSV delimiter character (supports escapes like '\\t').", +) +@click.option( + "--output", + "-o", + "output_csv", + type=click.Path(dir_okay=False), + required=True, + help="Path to write the CSV table to.", +) +def to_csv_cmd( + eval_json_paths: List[str], + glob_pattern: Optional[str], + selected_cols: tuple[str, ...], + list_keys: bool, + round_ndigits: Optional[int], + delimiter: str, + output_csv: str, +) -> None: + """ + Convert one or more `eval_results.json` files into a CSV table. + + The CSV contains one row per (pipeline, stage), derived from file paths like: + `/stage_/eval_results.json` + + Columns follow: `____`. + """ + paths: list[Path] = [Path(p) for p in eval_json_paths] + if glob_pattern: + paths.extend(sorted(Path().glob(glob_pattern))) + + if not paths: + raise click.ClickException("No input files provided. Pass paths or --glob.") + + available = _available_eval_result_keys(paths) + console.print("[bold]Available keys in inputs:[/bold]") + for k in available: + console.print(f" - {_measurement_key_to_col(k)}") + + if list_keys: + return + + allowlist = _DEFAULT_EVAL_RESULTS_ALLOWLIST + if selected_cols: + available_cols = {_measurement_key_to_col(k) for k in available} + missing = [c for c in selected_cols if c not in available_cols] + if missing: + raise click.ClickException( + "Selected keys not found in inputs:\n" + "\n".join(f"- {m}" for m in missing) + ) + + allowlist = {} + for c in selected_cols: + k = _col_to_measurement_key(c) + allowlist.setdefault(k.metric, {})[k.measurement] = k.unit + + out_path = Path(output_csv) + delimiter = _decode_single_char_delimiter(delimiter) + write_eval_csv( + paths, + out_path=out_path, + allowlist=allowlist, + delimiter=delimiter, + round_ndigits=round_ndigits, + ) + console.print(f"[green]✓ Wrote CSV to[/green] {out_path}") \ No newline at end of file diff --git a/src/kgpipe/cli/main.py b/src/kgpipe/cli/main.py index fba6104..6f3e0c0 100644 --- a/src/kgpipe/cli/main.py +++ b/src/kgpipe/cli/main.py @@ -20,6 +20,7 @@ from .clean import clean_cmd from .task import task_cmd from .discover import discover_cmd +from .eval_new import eval_new_cmd # from .rank import rank_cmd # Initialize Rich console for pretty output console = Console() @@ -81,6 +82,7 @@ def cli(ctx: click.Context, config: Optional[str], verbose: bool, quiet: bool): cli.add_command(clean_cmd) cli.add_command(task_cmd) cli.add_command(discover_cmd) +cli.add_command(eval_new_cmd) # cli.add_command(rank_cmd) if __name__ == "__main__": diff --git a/src/kgpipe/common/model/pipeline.py b/src/kgpipe/common/model/pipeline.py index 00495ef..8420c02 100644 --- a/src/kgpipe/common/model/pipeline.py +++ b/src/kgpipe/common/model/pipeline.py @@ -36,7 +36,13 @@ class KgPipePlan(BaseModel): seed: Optional[Data] = None source: Optional[Data] = None result: Optional[Data] = None - + + @staticmethod + def from_path(json_file: str) -> 'KgPipePlan': + with open(json_file, "r") as f: + json_data = json.load(f) + return KgPipePlan(**json_data) + # def __str__(self) -> str: # return f"KgTaskReport({self.task_name}, {self.status}, {self.duration:.2f}s)" @@ -51,6 +57,13 @@ class KgStageReport(BaseModel): status: str error: Optional[str] = None + @staticmethod + def from_path(json_file: str) -> 'KgStageReport': + with open(json_file, "r") as f: + json_data = json.load(f) + return KgStageReport(**json_data) + +KgPipeReport = KgStageReport KgPipelineRun = KgStageReport # @dataclass diff --git a/src/kgpipe/datasets/multipart_multisource.py b/src/kgpipe/datasets/multipart_multisource.py index 1389221..22f1d93 100644 --- a/src/kgpipe/datasets/multipart_multisource.py +++ b/src/kgpipe/datasets/multipart_multisource.py @@ -90,8 +90,8 @@ def _check(self): def read_csv(self) -> List[MatchesRow]: return read_matches_csv(self.file) -def read_entities_csv(path: Path) -> List[EntitiesRow]: - return [EntitiesRow(entity_id=row["entity_id"], entity_label=row["entity_label"], entity_type=row["entity_type"], dataset=row["dataset"]) for row in csv.DictReader(path.open("r"), delimiter="\t")] +def read_entities_csv(path: Path, delimiter: str = "\t") -> List[EntitiesRow]: + return [EntitiesRow(entity_id=row["entity_id"], entity_label=row["entity_label"], entity_type=row["entity_type"], dataset=row["dataset"]) for row in csv.DictReader(path.open("r"), delimiter=delimiter)] class VerifiedEntities(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) From 32b57909e2c708b43b4f458b317a4cadac71ac68 Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 10 Apr 2026 15:04:41 +0200 Subject: [PATCH 17/42] exp(moviekg): new eval for duprate and entity count using new eval api --- .../moviekg/evaluation/test_eval_refactor.py | 96 ++++++++++++++++--- 1 file changed, 82 insertions(+), 14 deletions(-) diff --git a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py index 285bae2..3fdee2f 100644 --- a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py +++ b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py @@ -17,6 +17,13 @@ from kgpipe.common.model.kg import KG from kgpipe.common.model.data import DataFormat import json +from dataclasses import asdict + +try: + from moviekg import config as moviekg_config +except Exception as e: + # These are integration-style tests that depend on local env/config files. + pytest.skip(f"MovieKG config not available for eval integration test: {e}", allow_module_level=True) # TODO # [ ] Dataset Reader (split,ref,source,metadata) # [ ] Pipeline Results Reader (stage,kg,plan,report,tmp_file) @@ -27,7 +34,7 @@ EX_BENCH_DATA_PATH = Path("/home/marvin/phd/data/moviekg/datasets/film_10k") -EX_INC_PIPE_DATA_PATH = Path("/home/marvin/phd/data/moviekg/output/large/rdf_a") +# EX_INC_PIPE_DATA_PATH = Path("/home/marvin/phd/data/moviekg/output/large/rdf_a") # TODO is a wrapper interface for now, Dataset needs refactor later # TODO can be abstracted and implemented to have direct method per type, so dict is not needed for access @@ -98,6 +105,32 @@ def evaluate_stage(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> Li config_dict = build_config_dict(i, pipe_data, bench_data) return Evaluator().run(tg, metrics, config_dict) + +def _stage_dirs(output_dir: Path) -> list[Path]: + stage_dirs = [p for p in output_dir.iterdir() if p.is_dir() and p.name.startswith("stage_")] + # stage_1, stage_2, ... + stage_dirs.sort(key=lambda p: int(p.name.split("_", 1)[1])) + return stage_dirs + + +def _metric_results_to_jsonable(results: list[MetricResult]) -> list[dict]: + """ + Convert `MetricResult` dataclasses to JSON-serializable dicts. + + `MetricResult.metric` is an object instance, so we store its key/classname. + """ + out: list[dict] = [] + for r in results: + metric_key = getattr(r.metric, "key", None) or r.metric.__class__.__name__ + out.append( + { + "metric": metric_key, + "summary": r.summary, + "measurements": [asdict(m) for m in r.measurements], + } + ) + return out + # EX_PIPE_DATA_PATH = Path("/home/marvin/phd/data/moviekg/output/small/rdf_a/stage_1") # def test_evaluate_stage(): # if not EX_PIPE_DATA_PATH.exists() or not EX_BENCH_DATA_PATH.exists(): @@ -111,16 +144,51 @@ def evaluate_stage(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> Li # for result in results: # print(render_metric_result(result, truncate=True, truncate_value=3)) -def test_evaluate_inc_stage(): - if not EX_INC_PIPE_DATA_PATH.exists() or not EX_BENCH_DATA_PATH.exists(): - pytest.skip("Local MovieKG inc data not available; test is an integration/WIP scaffold.") - - for i in range(1, 4): - pipe_data = KgPipeData.from_path(EX_INC_PIPE_DATA_PATH / f"stage_{i}") - bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) - results = evaluate_stage(i, pipe_data, bench_data) - - # render - print() # avoids pytest output being interleaved with print statements - for result in results: - print(render_metric_result(result, truncate=True, truncate_value=3)) \ No newline at end of file +# def test_evaluate_inc_stage(): +# if not EX_INC_PIPE_DATA_PATH.exists() or not EX_BENCH_DATA_PATH.exists(): +# pytest.skip("Local MovieKG inc data not available; test is an integration/WIP scaffold.") + +# for i in range(1, 4): +# pipe_data = KgPipeData.from_path(EX_INC_PIPE_DATA_PATH / f"stage_{i}") +# bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) +# results = evaluate_stage(i, pipe_data, bench_data) + +# # render +# print() # avoids pytest output being interleaved with print statements +# for result in results: +# print(render_metric_result(result, truncate=True, truncate_value=3)) + +@pytest.mark.parametrize( + "pipeline_name", + list[str](moviekg_config.pipeline_types.keys()) + list[str](moviekg_config.llm_pipeline_types.keys()), +) +def test_evaluate_new(pipeline_name: str): + """ + Boilerplate integration test that runs the new eval API for each pipeline + output under `OUTPUT_ROOT//stage_*`. + """ + output_dir = moviekg_config.OUTPUT_ROOT / pipeline_name + + if not output_dir.exists(): + pytest.skip(f"Pipeline output directory {output_dir} not found") + + stage_dirs = _stage_dirs(output_dir) + if not stage_dirs: + pytest.skip(f"No stage directories found under {output_dir}") + + # Uses the dataset selected/configured via `moviekg.config` env vars. + bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) + + for stage_dir in stage_dirs: + i = int(stage_dir.name.split("_", 1)[1]) + pipe_data = KgPipeData.from_path(stage_dir) + results = evaluate_stage(i=i, pipe_data=pipe_data, bench_data=bench_data) + + eval_results = _metric_results_to_jsonable(results) + with open(stage_dir / "eval_results.json", "w") as f: + json.dump(eval_results, f, indent=2) + print(f"Wrote results to {stage_dir / 'eval_results.json'}") + + # Smoke checks: we got metric results back for this stage. + assert isinstance(results, list) + assert results \ No newline at end of file From cd69c4e1a07484c89e8942d3a61e45b417f9c3d1 Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 27 Feb 2026 10:48:31 +0100 Subject: [PATCH 18/42] feature: parameter: vis scatter plot --- docs/metrics/entity_coverage.md | 66 +++++++++++++++++-- .../tests/test_visualization.py | 3 + .../visualization/__init__.py | 3 + 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/docs/metrics/entity_coverage.md b/docs/metrics/entity_coverage.md index 3e76cb3..84b4ff1 100644 --- a/docs/metrics/entity_coverage.md +++ b/docs/metrics/entity_coverage.md @@ -1,21 +1,73 @@ +# Entity Coverage Metric +The Entity Coverage metric evaluates how well source entities are integrated into the target knowledge graph. It measures the overlap between expected source entities and the entities actually present in the generated knowledge graph. +## Source Entity Integration Score -# Source Entitiy Integration Score +The metric compares a set of expected source entities (provided as a reference file) against the entities found in the knowledge graph. It calculates coverage based on entity URIs and labels. -# Entity Integration Score +## Input Format +The expected entities are provided in a CSV or JSON file with the following structure: + +**CSV Format:** ``` URI, LABEL, TYPE +http://example.org/entity1, "Entity Label 1", EntityType +http://example.org/entity2, "Entity Label 2", EntityType +``` + +**JSON Format:** +```json +{ + "http://example.org/entity1": { + "entity_label": "Entity Label 1", + "entity_type": "EntityType" + }, + "http://example.org/entity2": { + "entity_label": "Entity Label 2", + "entity_type": "EntityType" + } +} +``` + +## Calculation + +The metric performs the following steps: + +1. **Load expected entities**: Reads the entity dictionary from the provided file path +2. **Extract entity identifiers**: Collects URIs and labels from the expected entities +3. **Find entities in KG**: Searches the knowledge graph for entities matching by URI or label (using `rdfs:label`) +4. **Calculate overlap**: Counts how many expected entities are found in the KG + +The coverage score is calculated as: + ``` +coverage = overlapping_entities_count / expected_entities_count +``` + +Where: +- `overlapping_entities_count`: Number of expected entities found in the KG +- `expected_entities_count`: Total number of entities in the reference file -Set of entity type pairs -Make overlap on entity_type pairs +## Variants -intesection= -precission -recall= +The framework provides several variants of entity coverage metrics: +- **SourceEntityCoverageMetric**: Strict matching by URI and label +- **SourceEntityCoverageMetricSoft**: Fuzzy matching using label embeddings (threshold 0.95) +- **SourceTypedEntityCoverageMetric**: Matching based on entity type pairs, calculating precision and recall on entity-type combinations +## Usage +To use this metric in evaluation, provide the path to the verified source entities file in the reference configuration: + +```python +from kgpipe.evaluation.aspects.reference import ReferenceConfig + +config = ReferenceConfig( + VERIFIED_SOURCE_ENTITIES="path/to/entities.csv" +) +``` +The metric will automatically be included when evaluating with the `REFERENCE` aspect. diff --git a/src/kgpipe_parameters/tests/test_visualization.py b/src/kgpipe_parameters/tests/test_visualization.py index 0b19589..187b826 100644 --- a/src/kgpipe_parameters/tests/test_visualization.py +++ b/src/kgpipe_parameters/tests/test_visualization.py @@ -174,3 +174,6 @@ def test_scatter_too_few_points(self, tmp_path): path = viz.plot_embedding_scatter() assert path.exists() + + + diff --git a/src/kgpipe_parameters/visualization/__init__.py b/src/kgpipe_parameters/visualization/__init__.py index 0b7baa9..0bb436b 100644 --- a/src/kgpipe_parameters/visualization/__init__.py +++ b/src/kgpipe_parameters/visualization/__init__.py @@ -4,3 +4,6 @@ __all__ = ["ParameterVisualizer"] + + + From faa8cd230b678a98a3cb62ab32171e75c0e7de1c Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 19 Mar 2026 18:06:15 +0100 Subject: [PATCH 19/42] exp(params): added agreement-maker-light --- experiments/param-opti/input/Parameters.md | 17 +++++++++++++++++ .../input/am_light/parameters.properties | 3 +++ experiments/param-opti/input/am_light/repo.url | 1 + 3 files changed, 21 insertions(+) create mode 100644 experiments/param-opti/input/Parameters.md create mode 100644 experiments/param-opti/input/am_light/parameters.properties create mode 100644 experiments/param-opti/input/am_light/repo.url diff --git a/experiments/param-opti/input/Parameters.md b/experiments/param-opti/input/Parameters.md new file mode 100644 index 0000000..2dac3e7 --- /dev/null +++ b/experiments/param-opti/input/Parameters.md @@ -0,0 +1,17 @@ + +# Entity Matching +Algo +Cluster +Threshold + +# Ontology Matching +Algo +Cluster +Threshold + +# Entity Linking + +# Relation Linking + +# Fusion +Method \ No newline at end of file diff --git a/experiments/param-opti/input/am_light/parameters.properties b/experiments/param-opti/input/am_light/parameters.properties new file mode 100644 index 0000000..9a296d7 --- /dev/null +++ b/experiments/param-opti/input/am_light/parameters.properties @@ -0,0 +1,3 @@ +# manual file for parameters +similarity_threshold=0.7 +similarity_threshold_mapping=SIMILARITY_THRESHOLD diff --git a/experiments/param-opti/input/am_light/repo.url b/experiments/param-opti/input/am_light/repo.url new file mode 100644 index 0000000..7d29f7d --- /dev/null +++ b/experiments/param-opti/input/am_light/repo.url @@ -0,0 +1 @@ +https://github.com/AgreementMakerLight/AML-Project.git \ No newline at end of file From a46f7c66c767ab8fddacd2d7f9dde6aed404ba3b Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 19 Mar 2026 18:06:48 +0100 Subject: [PATCH 20/42] feat(params): init config_mapper idea (global to local tool specific param names) --- src/kgpipe_parameters/config_mapper.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/kgpipe_parameters/config_mapper.py diff --git a/src/kgpipe_parameters/config_mapper.py b/src/kgpipe_parameters/config_mapper.py new file mode 100644 index 0000000..a9d854b --- /dev/null +++ b/src/kgpipe_parameters/config_mapper.py @@ -0,0 +1,20 @@ + +""" +Maps a GLOBAL configuration to a local Parameter of a task implementation. +""" + +from kgpipe.common.model.configuration import Parameter, ConfigurationProfile +from kgpipe.common.model.task import KgTask, Data, TaskInput, TaskOutput, KgTask + +class ConfigMapper: + def __init__(self, task: KgTask): + self.task = task + + def map_config(self, config: ConfigurationMapping): + return self.task.config + + + + +def example_task(i: TaskInput, o: TaskOutput, p: ConfigurationProfile): + pass \ No newline at end of file From 86945dccc2d1a3e6c921e5ac29d962568f25158a Mon Sep 17 00:00:00 2001 From: Marvin Date: Thu, 2 Apr 2026 18:20:35 +0200 Subject: [PATCH 21/42] stash --- experiments/param-opti/README.md | 5 ++++ .../param_opti/pipeline_selection/__init__.py | 0 .../pipeline_selection/test_configuration.py | 26 +++++++++++++++++++ .../src/param_opti/tasks/__init__.py | 0 .../src/param_opti/tasks/agreementmaker.py | 14 ++++++++++ 5 files changed, 45 insertions(+) create mode 100644 experiments/param-opti/src/param_opti/pipeline_selection/__init__.py create mode 100644 experiments/param-opti/src/param_opti/pipeline_selection/test_configuration.py create mode 100644 experiments/param-opti/src/param_opti/tasks/__init__.py create mode 100644 experiments/param-opti/src/param_opti/tasks/agreementmaker.py diff --git a/experiments/param-opti/README.md b/experiments/param-opti/README.md index 8c62e8c..ad7e561 100644 --- a/experiments/param-opti/README.md +++ b/experiments/param-opti/README.md @@ -98,3 +98,8 @@ Results are saved as JSON files in `output/`: A `_summary.json` file is also generated with aggregate statistics. +# Configuration Apsects + +1. Task Assignment: Selecting +2. Task Tunning +3. \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/pipeline_selection/__init__.py b/experiments/param-opti/src/param_opti/pipeline_selection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/param_opti/pipeline_selection/test_configuration.py b/experiments/param-opti/src/param_opti/pipeline_selection/test_configuration.py new file mode 100644 index 0000000..926605f --- /dev/null +++ b/experiments/param-opti/src/param_opti/pipeline_selection/test_configuration.py @@ -0,0 +1,26 @@ +from random import random, seed, sample +from typing import List + + + +def entity_matching_a() -> List[str]: + seed(42) + # select 5 positive values and 5 negative values + positive_values=["+A", "+B", "+C", "+D", "+E", "+F", "+G", "+H", "+I", "+J", "+K", "+L", "+M", "+N", "+O", "+P", "+Q", "+R", "+S", "+T", "+U", "+V", "+W", "+X", "+Y", "+Z"] + negative_values=["-A", "-B", "-C", "-D", "-E", "-F", "-G", "-H", "-I", "-J", "-K", "-L", "-M", "-N", "-O", "-P", "-Q", "-R", "-S", "-T", "-U", "-V", "-W", "-X", "-Y", "-Z"] + positive_values = sample(positive_values, 5) + negative_values = sample(negative_values, 5) + return positive_values + negative_values + +def schmea_matching_a(): pass + +def test_selecting_pipelines(): pass + + +def test_run(): + + values = entity_matching_a() + print(values) + values2 = entity_matching_a() + print(values2) + # print(values == values2) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/__init__.py b/experiments/param-opti/src/param_opti/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/param_opti/tasks/agreementmaker.py b/experiments/param-opti/src/param_opti/tasks/agreementmaker.py new file mode 100644 index 0000000..716457b --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/agreementmaker.py @@ -0,0 +1,14 @@ +from kgpipe.common import Data, DataFormat, KgTask, Registry, TaskInput, TaskOutput, BasicTaskCategoryCatalog + +@Registry.task( + input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, + output_spec={"output": DataFormat.AGREEMENTMAKER_RDF}, + description="Perform entity matching using AgreementMaker", + category=[BasicTaskCategoryCatalog.entity_matching] +) +def entity_matching_aggrement_maker(inputs: TaskInput, outputs: TaskOutput): + """Perform entity matching using AgreementMaker.""" + source_data = inputs["source"] + target_data = inputs["target"] + output_data = outputs["output"] + return output_data \ No newline at end of file From 41e4d88e7f7b6208c6075f7b9eecd5471474ab55 Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 10 Apr 2026 15:14:44 +0200 Subject: [PATCH 22/42] exp(conf): added mockup experiments for paper --- experiments/param-opti/.gitignore | 3 ++- experiments/param-opti/README.md | 39 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/experiments/param-opti/.gitignore b/experiments/param-opti/.gitignore index 3d632f9..a4d684a 100644 --- a/experiments/param-opti/.gitignore +++ b/experiments/param-opti/.gitignore @@ -1,2 +1,3 @@ output/ -repos/ \ No newline at end of file +repos/ +output_qap_mock/ \ No newline at end of file diff --git a/experiments/param-opti/README.md b/experiments/param-opti/README.md index ad7e561..91e2f0f 100644 --- a/experiments/param-opti/README.md +++ b/experiments/param-opti/README.md @@ -2,6 +2,44 @@ This experiment extracts and analyzes configuration parameters from open-source data integration tools using the `kgpipe_parameters` extraction module. +## Paper mock experiments (Quality-Aware Pipelines) + +This directory also contains a **self-contained mock** of the experiments described in `Quality_Aware_Pipelines.pdf` (Section 6, “Experimental Evaluation”). + +- **What it is**: a small simulation of (a) a pipeline configuration space (implementations + thresholds), (b) a “true” end-to-end quality objective (accuracy/coverage/consistency aggregated), (c) an approximate quality estimator \( \hat{Q} \), and (d) search strategies (Default, Random Search, Quality-Aware Search). +- **What it is not**: it does **not** run KGpipe or reproduce the paper’s numbers. It’s meant as a scaffolding to iterate on the experimental protocol and factor out cleaner subpackages later. + +### Run the mock experiments + +From `experiments/param-opti`: + +```bash +python3 run_qap_mock.py all +python3 run_qap_mock.py exp1 # search effectiveness (Table-2-like) +python3 run_qap_mock.py exp2 # estimation reliability (corr/MAE/top-k) +python3 run_qap_mock.py exp3 # impl-only vs param-only vs joint +``` + +Outputs are written to `output_qap_mock/` (JSON). + +#### “Mock → real” execution mode + +The `qap_mock` package can now execute **real KGpipe tasks** (instead of purely simulated formulas) when dependencies are installed. + +- **Install dependencies** (from repo root): + +```bash +python3 -m pip install -e . +``` + +- **Enable docker-backed tasks** (PARIS, CoreNLP) for richer pipelines: + +```bash +export QAP_MOCK_USE_DOCKER=1 +``` + +Without `QAP_MOCK_USE_DOCKER=1`, `qap_mock` will use non-docker fallbacks where available (e.g., union-only RDF fusion and a lightweight pattern-based IE) so the experiment harness stays runnable. + ## Directory Structure ``` @@ -14,6 +52,7 @@ param-opti/ │ └── repo.url ├── repos/ # Cloned repositories (auto-populated) ├── output/ # Extraction results (JSON) +├── output_qap_mock/ # Mock paper experiment results (JSON) ├── src/ │ └── param_opti/ # Experiment code └── run_experiment.py # Main entry point From 28811be40f4f66b87eebcfeac1cd20afa51ca182 Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 10 Apr 2026 15:15:28 +0200 Subject: [PATCH 23/42] exp(conf): missing mockup code --- experiments/param-opti/run_qap_mock.py | 26 ++ .../param-opti/src/qap_mock/__init__.py | 15 + .../param-opti/src/qap_mock/__main__.py | 57 +++ .../param-opti/src/qap_mock/experiments.py | 204 +++++++++ experiments/param-opti/src/qap_mock/models.py | 37 ++ .../param-opti/src/qap_mock/objectives.py | 226 ++++++++++ .../param-opti/src/qap_mock/pipeline_util.py | 414 ++++++++++++++++++ experiments/param-opti/src/qap_mock/search.py | 138 ++++++ .../param-opti/src/qap_mock/search_space.py | 143 ++++++ experiments/param-opti/src/qap_mock/stats.py | 58 +++ 10 files changed, 1318 insertions(+) create mode 100644 experiments/param-opti/run_qap_mock.py create mode 100644 experiments/param-opti/src/qap_mock/__init__.py create mode 100644 experiments/param-opti/src/qap_mock/__main__.py create mode 100644 experiments/param-opti/src/qap_mock/experiments.py create mode 100644 experiments/param-opti/src/qap_mock/models.py create mode 100644 experiments/param-opti/src/qap_mock/objectives.py create mode 100644 experiments/param-opti/src/qap_mock/pipeline_util.py create mode 100644 experiments/param-opti/src/qap_mock/search.py create mode 100644 experiments/param-opti/src/qap_mock/search_space.py create mode 100644 experiments/param-opti/src/qap_mock/stats.py diff --git a/experiments/param-opti/run_qap_mock.py b/experiments/param-opti/run_qap_mock.py new file mode 100644 index 0000000..162042f --- /dev/null +++ b/experiments/param-opti/run_qap_mock.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +""" +Quick runner for the "Quality Aware Knowledge Graph Pipeline Configurations" +paper mock experiments. + +Run from this directory: + python run_qap_mock.py exp1 + python run_qap_mock.py exp2 + python run_qap_mock.py exp3 + python run_qap_mock.py all +""" + +import sys +from pathlib import Path + +# Add local experiment src + project src to path +exp_src_path = Path(__file__).parent / "src" +repo_src_path = Path(__file__).resolve().parents[2] / "src" +sys.path.insert(0, str(exp_src_path)) +sys.path.insert(0, str(repo_src_path)) + +from qap_mock.__main__ import main # noqa: E402 + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/experiments/param-opti/src/qap_mock/__init__.py b/experiments/param-opti/src/qap_mock/__init__.py new file mode 100644 index 0000000..ddd4d3e --- /dev/null +++ b/experiments/param-opti/src/qap_mock/__init__.py @@ -0,0 +1,15 @@ +""" +Mock implementation of the experiments described in `Quality_Aware_Pipelines.pdf`. + +This package is intentionally self-contained and does not depend on KGpipe. +It simulates: +- A small configuration space (implementations + parameters) +- A "true" end-to-end quality objective +- A correlated approximate quality estimator +- Search strategies (default, random, quality-aware) +""" + +from .models import PipelineFamily, SearchMethod + +__all__ = ["PipelineFamily", "SearchMethod"] + diff --git a/experiments/param-opti/src/qap_mock/__main__.py b/experiments/param-opti/src/qap_mock/__main__.py new file mode 100644 index 0000000..405c19e --- /dev/null +++ b/experiments/param-opti/src/qap_mock/__main__.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .experiments import ( + experiment_1_search_effectiveness, + experiment_2_estimation_reliability, + experiment_3_dimension_impact, +) + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + description="Mock experiments for Quality_Aware_Pipelines.pdf (quality-aware search)" + ) + p.add_argument( + "which", + choices=["exp1", "exp2", "exp3", "all"], + help="Which experiment(s) to run", + ) + p.add_argument( + "--outdir", + type=Path, + default=Path(__file__).parent.parent.parent / "output_qap_mock", + help="Output directory for JSON results", + ) + p.add_argument("--budget", type=int, default=20, help="Evaluation budget B (exp1/exp3)") + p.add_argument("--runs", type=int, default=5, help="Number of runs/seeds (exp1/exp3)") + p.add_argument("--samples", type=int, default=60, help="Number of sampled configs (exp2)") + + args = p.parse_args(argv) + + results: dict[str, object] = {} + + if args.which in ("exp1", "all"): + results["exp1"] = experiment_1_search_effectiveness( + outdir=args.outdir, budget=args.budget, runs=args.runs + ) + if args.which in ("exp2", "all"): + results["exp2"] = experiment_2_estimation_reliability( + outdir=args.outdir, n_samples=args.samples + ) + if args.which in ("exp3", "all"): + results["exp3"] = experiment_3_dimension_impact( + outdir=args.outdir, budget=args.budget, runs=args.runs + ) + + # Short stdout summary so it's easy to sanity-check runs. + print(json.dumps({"outdir": str(args.outdir), "ran": list(results.keys())}, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/experiments/param-opti/src/qap_mock/experiments.py b/experiments/param-opti/src/qap_mock/experiments.py new file mode 100644 index 0000000..e0ec17c --- /dev/null +++ b/experiments/param-opti/src/qap_mock/experiments.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import json +import random +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional + +from .models import PipelineFamily, SearchMethod, SearchSpaceMode +from .search import ( + best_so_far_curve, + evals_to_fraction_of_final_best, + run_search, +) +from .search_space import get_family_space, sample_config +from .stats import mae, mean, pearsonr, spearmanr, stdev, topk_agreement +from .objectives import evaluate_true_quality, estimate_quality_from_config + + +@dataclass +class Exp1Cell: + mean_best: float + std_best: float + mean_evals_to_95: Optional[float] + + def as_dict(self) -> dict: + return { + "best_score_mean": self.mean_best, + "best_score_std": self.std_best, + "evals_to_95_mean": self.mean_evals_to_95, + } + + +def _ensure_outdir(outdir: Path) -> None: + outdir.mkdir(parents=True, exist_ok=True) + + +def experiment_1_search_effectiveness( + *, + outdir: Path, + budget: int = 20, + runs: int = 5, + base_seed: int = 7, +) -> dict: + """ + Mirrors Section 6.3 / Table 2 narrative: + - Compare Default, Random Search, Quality-Aware Search + - Fixed budget B=20 + - Report best achieved score (mean ± std over 5 runs) + - Report mean evaluations to reach 95% of each run's final best + """ + _ensure_outdir(outdir) + + methods = [SearchMethod.DEFAULT] #, SearchMethod.RANDOM, SearchMethod.QUALITY_AWARE] + families = [PipelineFamily.RDF, PipelineFamily.TEXT] + + table: Dict[str, Dict[str, Exp1Cell]] = {} + raw: Dict[str, Dict[str, List[dict]]] = {} + + for fam in families: + fam_key = fam.value + table[fam_key] = {} + raw[fam_key] = {} + + for m in methods: + seeds = [base_seed + i for i in range(runs)] + bests: List[float] = [] + evals95: List[float] = [] + raw_runs: List[dict] = [] + + for i, s in enumerate(seeds): + recs = run_search( + seed=10_000 * (i + 1) + s, + family=fam, + method=m, + budget=budget, + mode=SearchSpaceMode.JOINT, + ) + curve = best_so_far_curve(recs) + bests.append(curve[-1]) + e95 = evals_to_fraction_of_final_best(curve, 0.95) + if e95 is not None: + evals95.append(float(e95)) + + raw_runs.append( + { + "seed": s, + "curve_best_so_far": curve, + } + ) + + cell = Exp1Cell( + mean_best=mean(bests), + std_best=stdev(bests) if m != SearchMethod.DEFAULT else float("nan"), + mean_evals_to_95=mean(evals95) if (m != SearchMethod.DEFAULT and evals95) else None, + ) + table[fam_key][m.value] = cell + raw[fam_key][m.value] = raw_runs + + result = { + "budget": budget, + "runs": runs, + "table": { + fam: {meth: cell.as_dict() for meth, cell in methods_.items()} + for fam, methods_ in table.items() + }, + "raw": raw, + } + + (outdir / "exp1_search_effectiveness.json").write_text(json.dumps(result, indent=2)) + return result + + +def experiment_2_estimation_reliability( + *, + outdir: Path, + n_samples: int = 60, + seed: int = 23, + topk: int = 10, +) -> dict: + """ + Mirrors Section 6.4 narrative: + - sample configurations + - compute estimated vs true scores + - compute correlation (Pearson/Spearman), MAE, top-k agreement + """ + _ensure_outdir(outdir) + + rng = random.Random(seed) + families = [PipelineFamily.RDF, PipelineFamily.TEXT] + + out: Dict[str, dict] = {"n_samples": n_samples, "topk": topk, "by_family": {}} + + for fam in families: + true_scores: List[float] = [] + est_scores: List[float] = [] + + for _ in range(n_samples): + cfg = sample_config(rng, fam, mode=SearchSpaceMode.JOINT) + true = evaluate_true_quality(rng, cfg).total + est = estimate_quality_from_config(rng, cfg) + true_scores.append(true) + est_scores.append(est) + + fam_key = fam.value + out["by_family"][fam_key] = { + "pearson": pearsonr(est_scores, true_scores), + "spearman": spearmanr(est_scores, true_scores), + "mae": mae(est_scores, true_scores), + "topk_agreement": topk_agreement(est_scores, true_scores, topk), + } + + (outdir / "exp2_estimation_reliability.json").write_text(json.dumps(out, indent=2)) + return out + + +def experiment_3_dimension_impact( + *, + outdir: Path, + budget: int = 20, + runs: int = 5, + base_seed: int = 101, +) -> dict: + """ + Mirrors Section 6.5 narrative: + Compare best scores for restricted spaces: + - implementation-only + - parameter-only + - joint + """ + _ensure_outdir(outdir) + + families = [PipelineFamily.RDF, PipelineFamily.TEXT] + modes = [ + SearchSpaceMode.IMPLEMENTATION_ONLY, + SearchSpaceMode.PARAMETER_ONLY, + SearchSpaceMode.JOINT, + ] + + out: Dict[str, dict] = {"budget": budget, "runs": runs, "by_family": {}} + + for fam in families: + fam_out: Dict[str, dict] = {} + for mode in modes: + bests: List[float] = [] + for i in range(runs): + seed = base_seed + i * 17 + recs = run_search( + seed=20_000 * (i + 1) + seed, + family=fam, + method=SearchMethod.QUALITY_AWARE, + budget=budget, + mode=mode, + ) + curve = best_so_far_curve(recs) + bests.append(curve[-1]) + + fam_out[mode.value] = {"best_mean": mean(bests), "best_std": stdev(bests)} + + out["by_family"][fam.value] = fam_out + + (outdir / "exp3_dimension_impact.json").write_text(json.dumps(out, indent=2)) + return out + diff --git a/experiments/param-opti/src/qap_mock/models.py b/experiments/param-opti/src/qap_mock/models.py new file mode 100644 index 0000000..5809be5 --- /dev/null +++ b/experiments/param-opti/src/qap_mock/models.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Mapping + + +class PipelineFamily(str, Enum): + RDF = "rdf" + TEXT = "text" + + +class SearchMethod(str, Enum): + DEFAULT = "default" + RANDOM = "random" + QUALITY_AWARE = "quality_aware" + + +class SearchSpaceMode(str, Enum): + JOINT = "joint" + IMPLEMENTATION_ONLY = "implementation_only" + PARAMETER_ONLY = "parameter_only" + + +@dataclass(frozen=True) +class PipelineConfig: + family: PipelineFamily + implementations: Mapping[str, str] + params: Mapping[str, float] + + def as_dict(self) -> dict: + return { + "family": self.family.value, + "implementations": dict(self.implementations), + "params": dict(self.params), + } + diff --git a/experiments/param-opti/src/qap_mock/objectives.py b/experiments/param-opti/src/qap_mock/objectives.py new file mode 100644 index 0000000..8691ebe --- /dev/null +++ b/experiments/param-opti/src/qap_mock/objectives.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import math +import random +from dataclasses import dataclass + +from .models import PipelineConfig, PipelineFamily +from .pipeline_util import ( + compute_rdf_metrics, + compute_te_metrics, + default_base_workdir, + run_pipeline_for_config, + TEST_DATA_ONTOLOGY_PATH, + # _test_data_path, +) + + +@dataclass(frozen=True) +class QualityBreakdown: + accuracy: float + coverage: float + consistency: float + total: float + + +def _sigmoid(x: float) -> float: + return 1.0 / (1.0 + math.exp(-x)) + + +def _base_quality_components(cfg: PipelineConfig) -> tuple[float, float, float]: + """ + Deterministic (noise-free) quality components for a configuration. + + This is used both for the simulated "true" evaluation (with added noise) + and for the approximate estimator (with different noise). + """ + if cfg.family == PipelineFamily.RDF: + impl_acc = 0.0 + impl_cov = 0.0 + impl_con = 0.0 + + om = cfg.implementations["ontology_matching"] + if om == "string_sim": + impl_con += 0.01 + elif om == "embedding_sim": + impl_acc += 0.05 + impl_cov += 0.02 + elif om == "hybrid": + impl_acc += 0.06 + impl_cov += 0.03 + impl_con += 0.01 + elif om == "llm_alignment": + impl_cov += 0.05 + impl_acc += 0.04 + impl_con -= 0.01 + + em = cfg.implementations["entity_matching"] + if em == "rule_based": + impl_con += 0.02 + elif em == "blocking_sim": + impl_acc += 0.04 + impl_cov += 0.02 + elif em == "embedding_er": + impl_acc += 0.06 + impl_cov += 0.03 + elif em == "llm_er": + impl_cov += 0.05 + impl_acc += 0.05 + impl_con -= 0.01 + + fu = cfg.implementations["fusion"] + if fu == "union": + impl_cov += 0.03 + elif fu == "majority_vote": + impl_con += 0.03 + impl_acc += 0.01 + elif fu == "quality_weighted": + impl_con += 0.06 + impl_acc += 0.02 + + s_thr = float(cfg.params["schema_sim_threshold"]) + e_thr = float(cfg.params["entity_sim_threshold"]) + f_thr = float(cfg.params["fusion_confidence_threshold"]) + bk = float(cfg.params.get("blocking_key_strength", 0.5)) + + acc = 0.55 + 0.18 * _sigmoid((s_thr - 0.65) * 8) + 0.18 * _sigmoid((e_thr - 0.65) * 8) + cov = 0.65 - 0.25 * _sigmoid((s_thr - 0.6) * 7) - 0.25 * _sigmoid((e_thr - 0.6) * 7) + con = 0.55 + 0.20 * _sigmoid((f_thr - 0.45) * 6) + + strict = (s_thr + e_thr) / 2.0 + con -= 0.05 * _sigmoid((strict - 0.85) * 10) + + cov += 0.03 * _sigmoid((bk - 0.3) * 6) + acc -= 0.02 * _sigmoid((bk - 0.8) * 10) + + acc += impl_acc + cov += impl_cov + con += impl_con + + return acc, cov, con + + if cfg.family == PipelineFamily.TEXT: + impl_acc = 0.0 + impl_cov = 0.0 + impl_con = 0.0 + + ie = cfg.implementations["information_extraction"] + if ie == "pattern_ie": + impl_con += 0.01 + elif ie == "openie": + impl_cov += 0.04 + impl_acc += 0.01 + elif ie == "hybrid_ie": + impl_cov += 0.06 + impl_acc += 0.02 + impl_con += 0.01 + elif ie == "llm_ie": + impl_cov += 0.08 + impl_acc += 0.03 + impl_con -= 0.01 + + el = cfg.implementations["entity_linking"] + if el == "dictionary_linking": + impl_cov += 0.02 + elif el == "embedding_linking": + impl_acc += 0.06 + elif el == "llm_linking": + impl_acc += 0.07 + impl_cov += 0.02 + impl_con -= 0.01 + + fu = cfg.implementations["fusion"] + if fu == "union": + impl_cov += 0.03 + elif fu == "majority_vote": + impl_con += 0.03 + impl_acc += 0.01 + elif fu == "quality_weighted": + impl_con += 0.07 + impl_acc += 0.02 + + ie_thr = float(cfg.params["ie_conf_threshold"]) + link_thr = float(cfg.params["link_sim_threshold"]) + f_thr = float(cfg.params["fusion_confidence_threshold"]) + cw = float(cfg.params.get("context_window", 256.0)) + + acc = 0.40 + 0.22 * _sigmoid((link_thr - 0.6) * 7) + 0.10 * _sigmoid((ie_thr - 0.55) * 6) + cov = 0.55 - 0.28 * _sigmoid((ie_thr - 0.55) * 7) - 0.18 * _sigmoid((link_thr - 0.6) * 6) + con = 0.45 + 0.22 * _sigmoid((f_thr - 0.45) * 6) + + noisy = (0.6 - ie_thr) + (0.6 - link_thr) + con -= 0.10 * _sigmoid(noisy * 6) + + cov += 0.03 * _sigmoid((cw - 160.0) / 60.0) + con -= 0.02 * _sigmoid((cw - 420.0) / 70.0) + + acc += impl_acc + cov += impl_cov + con += impl_con + + return acc, cov, con + + raise ValueError(f"Unknown family: {cfg.family}") + + +def evaluate_true_quality(rng: random.Random, cfg: PipelineConfig) -> QualityBreakdown: + """ + Real(ish) end-to-end objective: run a KGpipe pipeline for this config and + compute measurable proxy metrics from its outputs. + + Notes: + - This intentionally uses bundled `kgpipe_tasks/test/test_data` inputs so + the experiments are runnable out of the box. + - Metrics are proxy/reference-independent signals (no gold labels yet). + """ + base = default_base_workdir() + run = run_pipeline_for_config(cfg=cfg, base_workdir=base, stable_files=False) + + if cfg.family == PipelineFamily.RDF: + ontology = TEST_DATA_ONTOLOGY_PATH + m = compute_rdf_metrics(output_nt=run.final_output.path, ontology_ttl=ontology) + else: + m = compute_te_metrics(te_json_path=run.final_output.path) + + acc = min(1.0, max(0.0, float(m["accuracy"]))) + cov = min(1.0, max(0.0, float(m["coverage"]))) + con = min(1.0, max(0.0, float(m["consistency"]))) + + total = 0.45 * acc + 0.30 * cov + 0.25 * con + total = min(1.0, max(0.0, total)) + return QualityBreakdown(accuracy=acc, coverage=cov, consistency=con, total=total) + + +def estimate_quality_from_config(rng: random.Random, cfg: PipelineConfig) -> float: + """ + Approximate estimator Q-hat used by the quality-aware search to rank candidates + without executing the full pipeline. + + For now this remains a cheap heuristic over the config (so the search is not + dominated by expensive runs). The "true" objective is produced by actually + executing the pipeline in `evaluate_true_quality`. + """ + acc, cov, con = _base_quality_components(cfg) + # Estimator has its own noise and slight systematic distortion. + if cfg.family == PipelineFamily.RDF: + acc += rng.gauss(0.0, 0.015) + cov += rng.gauss(0.0, 0.015) + con += rng.gauss(0.0, 0.015) + else: + acc += rng.gauss(0.0, 0.020) + cov += rng.gauss(0.0, 0.020) + con += rng.gauss(0.0, 0.020) + + acc = min(1.0, max(0.0, acc)) + cov = min(1.0, max(0.0, cov)) + con = min(1.0, max(0.0, con)) + est = 0.45 * acc + 0.30 * cov + 0.25 * con + return min(1.0, max(0.0, est)) + + +def estimate_quality(rng: random.Random, true_total: float, family: PipelineFamily) -> float: + raise RuntimeError( + "estimate_quality(true_total, family) is deprecated; " + "use estimate_quality_from_config(rng, cfg) instead." + ) + diff --git a/experiments/param-opti/src/qap_mock/pipeline_util.py b/experiments/param-opti/src/qap_mock/pipeline_util.py new file mode 100644 index 0000000..e5f0890 --- /dev/null +++ b/experiments/param-opti/src/qap_mock/pipeline_util.py @@ -0,0 +1,414 @@ +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, Iterable, Optional + +if TYPE_CHECKING: + from kgpipe.common import Data, DataFormat, KgPipe, KgTask # pragma: no cover + from kgpipe.common.model.task import KgTaskReport # pragma: no cover + +from .models import PipelineConfig, PipelineFamily + + +@dataclass(frozen=True) +class PipelineRunResult: + family: PipelineFamily + cfg: PipelineConfig + workdir: Path + final_output: Any # Data + task_reports: Any # list[KgTaskReport] + aux: dict + + +TEST_DATA_SEED_KG_PATH = Path("/home/marvin/project/data/final/film_1k/split_0/kg/seed/data.nt") +TEST_DATA_ONTOLOGY_PATH = Path("/home/marvin/project/data/final/film_1k/movie-ontology.ttl") +TEST_DATA_RDF_PATH = Path("/home/marvin/project/data/final/film_1k/split_1/sources/rdf/data.nt") +TEST_DATA_TEXT_PATH = Path("/home/marvin/project/data/final/film_1k/split_1/sources/text/data/") + +def _import_tasks_for_family(family: PipelineFamily) -> None: + """ + Import task modules so their @Registry.task decorators execute. + + This keeps the rest of qap_mock independent from kgpipe_tasks import side effects. + """ + # RDF: PARIS matcher + exchange + fusion tasks. + if family == PipelineFamily.RDF: + # Entity matching (docker) + exchange (python) + import kgpipe_tasks.entity_resolution.matcher.paris_rdf_matcher # noqa: F401 + import kgpipe_tasks.entity_resolution.entity_match # noqa: F401 + + # Fusion (python) + import kgpipe_tasks.entity_resolution.fusion.union # noqa: F401 + import kgpipe_tasks.entity_resolution.fusion.preference # noqa: F401 + + return + + if family == PipelineFamily.TEXT: + # CoreNLP OpenIE extraction (docker) + exchange (python) + import kgpipe_tasks.text_processing.text_extraction.corenlp_extraction # noqa: F401 + + return + + raise ValueError(f"Unknown family: {family}") + + +def _cfg_hash(cfg: PipelineConfig) -> str: + payload = json.dumps(cfg.as_dict(), sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(payload).hexdigest()[:16] + + +def _ensure_dir(p: Path) -> None: + p.mkdir(parents=True, exist_ok=True) + + +# def _test_data_path(relative_path: str) -> Path: +# """ +# Use kgpipe_tasks' bundled test data as default inputs so qap_mock is runnable. +# """ +# base = Path(__file__).resolve().parents[3] / "src" / "kgpipe_tasks" / "test" / "test_data" +# path = (base / relative_path).resolve() +# if not path.exists(): +# raise FileNotFoundError(f"Missing test data file: {path}") +# return path + + +def _set_env_from_params(params: dict[str, float]) -> dict[str, Optional[str]]: + """ + Apply a minimal mapping from qap_mock params to the env-var based configuration + convention used by many kgpipe tasks. + + Returns a dict of previous env values so callers can restore them. + """ + # Only set variables that are known to be read by the tasks we use. + mapping: dict[str, tuple[str, float]] = { + # RDF fusion/preference tasks + "ENTITY_MATCHING_THRESHOLD": ("entity_sim_threshold", 0.7), + "RELATION_MATCHING_THRESHOLD": ("schema_sim_threshold", 0.7), + # Text: no stable env knobs used by CoreNLP task today + } + + prev: dict[str, Optional[str]] = {} + for env_key, (p_key, default) in mapping.items(): + prev[env_key] = os.environ.get(env_key) + val = float(params.get(p_key, default)) + os.environ[env_key] = str(val) + return prev + + +def _restore_env(prev: dict[str, Optional[str]]) -> None: + for k, v in prev.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +def build_pipeline_for_config(*, cfg: PipelineConfig, workdir: Path) -> tuple[KgPipe, Data, Data]: + """ + Build a runnable KgPipe for the given configuration. + + We intentionally keep the mapping small and explicit: + - RDF: (optional) PARIS entity matching -> exchange -> fusion + - TEXT: CoreNLP OpenIE extraction (docker) -> exchange + + Returns (pipe, source, final_result_data). + """ + from kgpipe.common import Data, DataFormat, KgPipe, KgTask, Registry + + _import_tasks_for_family(cfg.family) + _ensure_dir(workdir) + + if cfg.family == PipelineFamily.RDF: + # Inputs: source + target (as seed) are bundled test fixtures. + source = Data(path=TEST_DATA_RDF_PATH, format=DataFormat.RDF_NTRIPLES) + target = Data(path=TEST_DATA_SEED_KG_PATH, format=DataFormat.RDF_NTRIPLES) + + # Ensure ontology env is set for fusion tasks that need it. + ontology_path = TEST_DATA_ONTOLOGY_PATH + os.environ.setdefault("ONTOLOGY_PATH", str(ontology_path)) + + # Decide whether to run entity matching. If we don't, we can still + # compute a meaningful output via simple union. + entity_impl = cfg.implementations.get("entity_matching", "rule_based") + fusion_impl = cfg.implementations.get("fusion", "union") + use_docker = os.environ.get("QAP_MOCK_USE_DOCKER", "0") == "1" + + tasks: list[KgTask] = [] + final_format = DataFormat.RDF_NTRIPLES + + def _empty_er(inputs: dict[str, Data], outputs: dict[str, Data]) -> None: + out_path = Path(outputs["output"].path) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps({"matches": [], "blocks": [], "clusters": []}, indent=2), encoding="utf-8") + + # dummy_entity_matching = KgTask( + # name="dummy_entity_matching", + # input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES}, + # output_spec={"output": DataFormat.ER_JSON}, + # function=_empty_er, + # description="Dummy matcher emitting empty ER_JSON (no docker)", + # ) + + # if entity_impl != "rule_based": + # if use_docker: + tasks.extend( + [ + Registry.get_task("paris_entity_matching"), + Registry.get_task("paris_exchange"), + ] + ) + # When matches exist, prefer a fusion strategy that uses them. + if fusion_impl in ("quality_weighted", "majority_vote"): + tasks.append(Registry.get_task("fusion_first_value")) + else: + tasks.append(Registry.get_task("union_matched_rdf")) + # else: + # # Non-docker mode: skip PARIS and run a deterministic empty matcher. + # tasks.extend([dummy_entity_matching, Registry.get_task("union_matched_rdf")]) + # else: + # # No matching step: just union the two graphs. + # tasks.append(Registry.get_task("fusion_union_rdf")) + + # seed is the "kg"/target, which KgPipe.build will use when a task + # declares an input named "kg". + pipe = KgPipe(tasks=tasks, seed=target, data_dir=str(workdir), name=f"qap_mock_{cfg.family.value}") + + final = Data(path=workdir / "final.nt", format=final_format) + return pipe, source, final + + if cfg.family == PipelineFamily.TEXT: + text = Data(path=TEST_DATA_TEXT_PATH, format=DataFormat.TEXT) + + ie_impl = cfg.implementations.get("information_extraction", "pattern_ie") + + def _pattern_ie(inputs: dict[str, Data], outputs: dict[str, Data]) -> None: + import re + + in_path = Path(inputs["input"].path) + out_path = Path(outputs["output"].path) + out_path.mkdir(parents=True, exist_ok=True) + + txt = _read_text(in_path) + # Tiny, deterministic pattern extractor: "X is a Y" / "X is an Y". + triples = [] + for m in re.finditer(r"([A-Z][A-Za-z0-9_ ]{2,40}) is an? ([A-Za-z][A-Za-z0-9_ -]{2,40})", txt): + subj = m.group(1).strip() + obj = m.group(2).strip() + triples.append( + { + "subject": {"surface_form": subj}, + "predicate": {"surface_form": "is_a"}, + "object": {"surface_form": obj}, + } + ) + + doc = {"text": txt[:10_000], "triples": triples, "chains": [], "links": []} + (out_path / "pattern_ie.te.json").write_text(json.dumps(doc), encoding="utf-8") + + pattern_ie_task = KgTask( + name="pattern_ie_extraction", + input_spec={"input": DataFormat.TEXT}, + output_spec={"output": DataFormat.TE_JSON}, + function=_pattern_ie, + description="Lightweight pattern IE (no docker)", + ) + + if ie_impl == "pattern_ie": + tasks = [pattern_ie_task] + else: + use_docker = os.environ.get("QAP_MOCK_USE_DOCKER", "0") == "1" + if use_docker: + # Use CoreNLP OpenIE path for openie/hybrid/llm variants (docker-backed). + tasks = [ + Registry.get_task("corenlp_openie_extraction"), + Registry.get_task("corenlp_exchange"), + ] + else: + # Default to the lightweight extractor when docker isn't enabled. + tasks = [pattern_ie_task] + + pipe = KgPipe(tasks=tasks, seed=text, data_dir=str(workdir), name=f"qap_mock_{cfg.family.value}") + # Many TE_JSON-producing tasks treat the output as a directory of documents. + final = Data(path=workdir / "final_te", format=DataFormat.TE_JSON) + return pipe, text, final + + raise ValueError(f"Unknown family: {cfg.family}") + + +def run_pipeline_for_config( + *, cfg: PipelineConfig, base_workdir: Path, stable_files: bool = True +) -> PipelineRunResult: + """ + Execute a real KGpipe pipeline for this config and return its artifacts. + + Results are cached by (family, cfg-hash) under base_workdir to avoid repeating + expensive docker/service calls during search. + """ + run_id = f"{cfg.family.value}_{_cfg_hash(cfg)}" + workdir = base_workdir / run_id + _ensure_dir(workdir) + + try: + pipe, source, final = build_pipeline_for_config(cfg=cfg, workdir=workdir) + except ModuleNotFoundError as e: + raise RuntimeError( + "KGpipe dependencies are not installed in this environment. " + "To run the *real* (non-mock) execution path, install the project in editable mode:\n\n" + " python3 -m pip install -e .\n\n" + "This will also install the `kgcore` dependency declared in `pyproject.toml`.\n" + f"Original import error: {e}" + ) from e + + # Apply env-var config mapping used by tasks. + prev_env = _set_env_from_params(dict(cfg.params)) + try: + # If final exists and stable_files=True, KgTask.run will skip; still ok. + pipe.build(source=source, result=final, stable_files=stable_files) + reports = pipe.run(stable_files_override=stable_files) + finally: + _restore_env(prev_env) + + return PipelineRunResult( + family=cfg.family, + cfg=cfg, + workdir=workdir, + final_output=final, + task_reports=reports, + aux={"source": str(source.path), "seed": str(pipe.seed.path), "run_id": run_id}, + ) + + +def _read_text(path: Path, max_bytes: int = 4_000_000) -> str: + # Keep it simple and avoid huge reads in case a docker task goes wild. + data = path.read_bytes() + if len(data) > max_bytes: + data = data[:max_bytes] + return data.decode("utf-8", errors="replace") + + +def compute_rdf_metrics(*, output_nt: Path, ontology_ttl: Optional[Path] = None) -> dict[str, float]: + import importlib + + try: + rdflib = importlib.import_module("rdflib") + Graph = getattr(rdflib, "Graph") + URIRef = getattr(importlib.import_module("rdflib.term"), "URIRef") + g = Graph() + g.parse(output_nt, format="nt") + triples = len(g) + except Exception: + # Fallback without rdflib: approximate triples by counting lines. + txt = _read_text(output_nt) + triples = len([ln for ln in txt.splitlines() if ln.strip() and not ln.strip().startswith("#")]) + Graph = None # type: ignore[assignment] + URIRef = None # type: ignore[assignment] + g = None # type: ignore[assignment] + + # Consistency proxy: fraction of predicates that appear in ontology (or common RDF vocab). + allowed: set[str] = set() + if Graph is not None and URIRef is not None and ontology_ttl is not None and ontology_ttl.exists(): + try: + og = Graph() + og.parse(ontology_ttl) + # Allow all predicates defined as properties + rdfs:label/rdf:type. + for s, _, _ in og: + # cheap heuristic: treat all subjects that are URIRefs as "allowed" predicates + if isinstance(s, URIRef): + allowed.add(str(s)) + allowed.add("http://www.w3.org/2000/01/rdf-schema#label") + allowed.add("http://www.w3.org/1999/02/22-rdf-syntax-ns#type") + except Exception: + allowed = set() + + if allowed and g is not None and URIRef is not None: + ok = 0 + for _, p, _ in g: + if isinstance(p, URIRef) and str(p) in allowed: + ok += 1 + consistency = ok / max(1, triples) + else: + consistency = 0.5 + + # Coverage proxy: normalize by union of input graphs when using bundled test data. + try: + src = Graph().parse(TEST_DATA_RDF_PATH, format="nt") + tgt = Graph().parse(TEST_DATA_SEED_KG_PATH, format="nt") + union_triples = len(src) + len(tgt) + coverage = min(1.0, triples / max(1, union_triples)) + except Exception: + coverage = min(1.0, triples / 10_000.0) + + # Accuracy proxy: reward non-trivial graphs (very small outputs are likely bad). + accuracy = min(1.0, max(0.0, (triples / 2000.0))) + + return {"accuracy": float(accuracy), "coverage": float(coverage), "consistency": float(consistency)} + + +def compute_te_metrics(*, te_json_path: Path) -> dict[str, float]: + """ + Compute lightweight metrics from TE_JSON outputs. + + This intentionally avoids requiring a gold standard. It's a pragmatic proxy: + - coverage ~ extracted triples count + - consistency ~ fraction of triples that have all 3 spans populated + - accuracy ~ average link score if links exist, else a baseline + """ + # TE_JSON may be a directory (many files) or a single file. + triples = 0 + complete = 0 + link_scores: list[float] = [] + + paths: Iterable[Path] + if te_json_path.is_dir(): + paths = [p for p in te_json_path.iterdir() if p.is_file()] + else: + paths = [te_json_path] + + for p in paths: + try: + doc = json.loads(_read_text(p)) + except Exception: + continue + for t in doc.get("triples", []) or []: + triples += 1 + s = (t.get("subject") or {}).get("surface_form") + r = (t.get("predicate") or {}).get("surface_form") + o = (t.get("object") or {}).get("surface_form") + if s and r and o: + complete += 1 + for l in doc.get("links", []) or []: + try: + link_scores.append(float(l.get("score", 0.0))) + except Exception: + pass + + # Normalize coverage against a rough scale for the bundled Hobbit text. + coverage = min(1.0, triples / 5000.0) + consistency = complete / max(1, triples) if triples else 0.0 + accuracy = (sum(link_scores) / len(link_scores)) if link_scores else 0.35 + accuracy = min(1.0, max(0.0, accuracy)) + + return {"accuracy": float(accuracy), "coverage": float(coverage), "consistency": float(consistency)} + + +def default_base_workdir() -> Path: + # Keep outputs inside the experiment folder by default. + return Path(__file__).resolve().parents[2] / "output_qap_mock" / "_real_runs" + + +def make_temp_base_workdir() -> Path: + return Path(tempfile.mkdtemp(prefix="qap_mock_real_")) + + +# - pipeline auto algo +# - cleaning +# normalization task +# - pipeline task aggregation +# aggregate multiple task sub (DAGs) into a single task +# example: paris matching and fusion are two sub tasks, we can aggregate them into a single task + diff --git a/experiments/param-opti/src/qap_mock/search.py b/experiments/param-opti/src/qap_mock/search.py new file mode 100644 index 0000000..1d6c38d --- /dev/null +++ b/experiments/param-opti/src/qap_mock/search.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import random +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple + +from .models import PipelineConfig, PipelineFamily, SearchMethod, SearchSpaceMode +from .objectives import QualityBreakdown, estimate_quality_from_config, evaluate_true_quality +from .search_space import get_family_space, mutate_config, sample_config + + +@dataclass +class EvaluationRecord: + cfg: PipelineConfig + true: QualityBreakdown + est_total: float + + def as_dict(self) -> dict: + return { + "config": self.cfg.as_dict(), + "true": { + "accuracy": self.true.accuracy, + "coverage": self.true.coverage, + "consistency": self.true.consistency, + "total": self.true.total, + }, + "estimated_total": self.est_total, + } + + +def _eval_once(rng: random.Random, cfg: PipelineConfig) -> EvaluationRecord: + true = evaluate_true_quality(rng, cfg) + est = estimate_quality_from_config(rng, cfg) + return EvaluationRecord(cfg=cfg, true=true, est_total=est) + + +def run_search( + *, + seed: int, + family: PipelineFamily, + method: SearchMethod, + budget: int, + mode: SearchSpaceMode = SearchSpaceMode.JOINT, +) -> List[EvaluationRecord]: + rng = random.Random(seed) + space = get_family_space(family) + default_cfg = PipelineConfig(family=family, implementations=space.default_impl, params=space.default_params) + + records: List[EvaluationRecord] = [] + + if method == SearchMethod.DEFAULT: + records.append(_eval_once(rng, default_cfg)) + return records + + if method == SearchMethod.RANDOM: + for _ in range(budget): + cfg = sample_config(rng, family, mode=mode, fixed_default=default_cfg) + records.append(_eval_once(rng, cfg)) + return records + + if method == SearchMethod.QUALITY_AWARE: + # Simple, explainable heuristic: + # - start from default + # - maintain incumbent based on estimated quality (Q-hat) + # - propose new configs by mutating incumbent (exploitation) + # - occasional random exploration + incumbent = default_cfg + incumbent_est: Optional[float] = None + + for t in range(budget): + # "Lookahead" using cheap quality estimates: generate a pool of + # candidates, pick the one with best estimated quality, then + # spend one "real" evaluation budget on it. + pool_size = 12 if t < 5 else 8 + candidates: List[PipelineConfig] = [] + for _ in range(pool_size): + explore = rng.random() < (0.35 if t < 3 else 0.20) + if explore: + candidates.append(sample_config(rng, family, mode=mode, fixed_default=default_cfg)) + else: + candidates.append( + mutate_config( + rng, + incumbent, + mode=mode, + p_change_impl=0.70, + p_change_param=0.85, + ) + ) + + best_est = None + best_cfg = None + for c in candidates: + est = estimate_quality_from_config(rng, c) + if best_est is None or est > best_est: + best_est = est + best_cfg = c + + assert best_cfg is not None + cfg = best_cfg + + rec = _eval_once(rng, cfg) + records.append(rec) + + if incumbent_est is None or rec.est_total > incumbent_est: + incumbent = cfg + incumbent_est = rec.est_total + + return records + + raise ValueError(f"Unknown method: {method}") + + +def best_so_far_curve(records: List[EvaluationRecord]) -> List[float]: + best = -1.0 + curve: List[float] = [] + for r in records: + best = max(best, r.true.total) + curve.append(best) + return curve + + +def evals_to_fraction_of_final_best(curve: List[float], fraction: float) -> Optional[int]: + if not curve: + return None + final_best = curve[-1] + target = fraction * final_best + for i, v in enumerate(curve, start=1): + if v >= target: + return i + return None + + +def summarize_best(records: List[EvaluationRecord]) -> Tuple[float, float]: + curve = best_so_far_curve(records) + best = curve[-1] if curve else float("nan") + return best, best + diff --git a/experiments/param-opti/src/qap_mock/search_space.py b/experiments/param-opti/src/qap_mock/search_space.py new file mode 100644 index 0000000..c69f031 --- /dev/null +++ b/experiments/param-opti/src/qap_mock/search_space.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import random +from dataclasses import dataclass +from typing import Dict, List, Tuple + +from .models import PipelineConfig, PipelineFamily, SearchSpaceMode + + +@dataclass(frozen=True) +class FamilySpace: + tasks: List[str] + impl_choices: Dict[str, List[str]] + param_ranges: Dict[str, Tuple[float, float]] + default_impl: Dict[str, str] + default_params: Dict[str, float] + + +def get_family_space(family: PipelineFamily) -> FamilySpace: + # Compact but expressive, mirroring the paper text: + # - discrete implementation choices per task + # - continuous thresholds + if family == PipelineFamily.RDF: + tasks = ["ontology_matching", "entity_matching", "fusion"] + impl_choices = { + "ontology_matching": ["string_sim", "embedding_sim", "hybrid", "llm_alignment"], + "entity_matching": ["rule_based", "blocking_sim", "embedding_er", "llm_er"], + "fusion": ["union", "quality_weighted", "majority_vote"], + } + param_ranges = { + "schema_sim_threshold": (0.3, 0.95), + "entity_sim_threshold": (0.3, 0.95), + "fusion_confidence_threshold": (0.1, 0.9), + "blocking_key_strength": (0.0, 1.0), + } + default_impl = { + "ontology_matching": "string_sim", + "entity_matching": "rule_based", + "fusion": "union", + } + default_params = { + "schema_sim_threshold": 0.7, + "entity_sim_threshold": 0.7, + "fusion_confidence_threshold": 0.5, + "blocking_key_strength": 0.5, + } + return FamilySpace(tasks, impl_choices, param_ranges, default_impl, default_params) + + if family == PipelineFamily.TEXT: + tasks = ["information_extraction", "entity_linking", "fusion"] + impl_choices = { + "information_extraction": ["pattern_ie", "openie", "hybrid_ie", "llm_ie"], + "entity_linking": ["dictionary_linking", "embedding_linking", "llm_linking"], + "fusion": ["union", "quality_weighted", "majority_vote"], + } + param_ranges = { + "ie_conf_threshold": (0.2, 0.95), + "link_sim_threshold": (0.2, 0.95), + "fusion_confidence_threshold": (0.1, 0.9), + "context_window": (64.0, 512.0), + } + default_impl = { + "information_extraction": "pattern_ie", + "entity_linking": "dictionary_linking", + "fusion": "union", + } + default_params = { + "ie_conf_threshold": 0.6, + "link_sim_threshold": 0.6, + "fusion_confidence_threshold": 0.5, + "context_window": 256.0, + } + return FamilySpace(tasks, impl_choices, param_ranges, default_impl, default_params) + + raise ValueError(f"Unknown family: {family}") + + +def sample_config( + rng: random.Random, + family: PipelineFamily, + mode: SearchSpaceMode = SearchSpaceMode.JOINT, + fixed_default: PipelineConfig | None = None, +) -> PipelineConfig: + space = get_family_space(family) + + impl: Dict[str, str] = {} + params: Dict[str, float] = {} + + if fixed_default is None: + fixed_default = PipelineConfig(family=family, implementations=space.default_impl, params=space.default_params) + + if mode in (SearchSpaceMode.JOINT, SearchSpaceMode.IMPLEMENTATION_ONLY): + for t in space.tasks: + impl[t] = rng.choice(space.impl_choices[t]) + else: + impl = dict(fixed_default.implementations) + + if mode in (SearchSpaceMode.JOINT, SearchSpaceMode.PARAMETER_ONLY): + for p, (lo, hi) in space.param_ranges.items(): + params[p] = rng.uniform(lo, hi) + else: + params = dict(fixed_default.params) + + return PipelineConfig(family=family, implementations=impl, params=params) + + +def mutate_config( + rng: random.Random, + cfg: PipelineConfig, + mode: SearchSpaceMode = SearchSpaceMode.JOINT, + p_change_impl: float = 0.35, + p_change_param: float = 0.8, +) -> PipelineConfig: + space = get_family_space(cfg.family) + impl = dict(cfg.implementations) + params = dict(cfg.params) + + if mode in (SearchSpaceMode.JOINT, SearchSpaceMode.IMPLEMENTATION_ONLY) and rng.random() < p_change_impl: + t = rng.choice(space.tasks) + choices = [c for c in space.impl_choices[t] if c != impl[t]] + if choices: + impl[t] = rng.choice(choices) + # Occasionally flip a second task implementation to escape local optima. + if rng.random() < 0.25: + t2 = rng.choice([x for x in space.tasks if x != t]) + choices2 = [c for c in space.impl_choices[t2] if c != impl[t2]] + if choices2: + impl[t2] = rng.choice(choices2) + + if mode in (SearchSpaceMode.JOINT, SearchSpaceMode.PARAMETER_ONLY) and rng.random() < p_change_param: + p = rng.choice(list(space.param_ranges.keys())) + lo, hi = space.param_ranges[p] + # Gaussian step with clipping keeps changes local. + step = rng.gauss(0.0, (hi - lo) * 0.08) + params[p] = min(hi, max(lo, params[p] + step)) + if rng.random() < 0.25: + p2 = rng.choice([x for x in space.param_ranges.keys() if x != p]) + lo2, hi2 = space.param_ranges[p2] + step2 = rng.gauss(0.0, (hi2 - lo2) * 0.06) + params[p2] = min(hi2, max(lo2, params[p2] + step2)) + + return PipelineConfig(family=cfg.family, implementations=impl, params=params) + diff --git a/experiments/param-opti/src/qap_mock/stats.py b/experiments/param-opti/src/qap_mock/stats.py new file mode 100644 index 0000000..9232ff1 --- /dev/null +++ b/experiments/param-opti/src/qap_mock/stats.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import math +from typing import Iterable, List, Sequence, Tuple + + +def mean(xs: Sequence[float]) -> float: + return sum(xs) / len(xs) if xs else float("nan") + + +def stdev(xs: Sequence[float]) -> float: + if len(xs) < 2: + return float("nan") + m = mean(xs) + return math.sqrt(sum((x - m) ** 2 for x in xs) / (len(xs) - 1)) + + +def rankdata(xs: Sequence[float]) -> List[int]: + # Simple dense ranking (ties get same rank). + sorted_unique = sorted(set(xs)) + rank = {v: i + 1 for i, v in enumerate(sorted_unique)} + return [rank[v] for v in xs] + + +def pearsonr(x: Sequence[float], y: Sequence[float]) -> float: + if len(x) != len(y) or len(x) < 2: + return float("nan") + mx = mean(x) + my = mean(y) + num = sum((a - mx) * (b - my) for a, b in zip(x, y)) + denx = math.sqrt(sum((a - mx) ** 2 for a in x)) + deny = math.sqrt(sum((b - my) ** 2 for b in y)) + if denx == 0.0 or deny == 0.0: + return float("nan") + return num / (denx * deny) + + +def spearmanr(x: Sequence[float], y: Sequence[float]) -> float: + rx = rankdata(x) + ry = rankdata(y) + return pearsonr(rx, ry) + + +def mae(x: Sequence[float], y: Sequence[float]) -> float: + if len(x) != len(y) or not x: + return float("nan") + return sum(abs(a - b) for a, b in zip(x, y)) / len(x) + + +def topk_agreement(x: Sequence[float], y: Sequence[float], k: int) -> float: + if len(x) != len(y) or not x: + return float("nan") + n = len(x) + k = max(1, min(k, n)) + topx = set(sorted(range(n), key=lambda i: x[i], reverse=True)[:k]) + topy = set(sorted(range(n), key=lambda i: y[i], reverse=True)[:k]) + return len(topx & topy) / k + From ec08d89d727ad5cf482ef945492498bf552a75db Mon Sep 17 00:00:00 2001 From: Marvin Date: Fri, 10 Apr 2026 15:15:48 +0200 Subject: [PATCH 24/42] fix: fusion task imports --- src/kgpipe_tasks/entity_resolution/fusion/union.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/kgpipe_tasks/entity_resolution/fusion/union.py b/src/kgpipe_tasks/entity_resolution/fusion/union.py index 3af3e01..f31f49d 100644 --- a/src/kgpipe_tasks/entity_resolution/fusion/union.py +++ b/src/kgpipe_tasks/entity_resolution/fusion/union.py @@ -7,8 +7,9 @@ import json from kgpipe.common.registry import Registry import os -from kgcore.model.ontology import OntologyUtil -from kgpipe.execution.config import SOURCE_NAMESPACE, TARGET_ONTOLOGY_NAMESPACE, TARGET_RESOURCE_NAMESPACE + +from kgcore.api.ontology import OntologyUtil +from kgpipe.common.config import SOURCE_NAMESPACE, TARGET_ONTOLOGY_NAMESPACE, TARGET_RESOURCE_NAMESPACE def fuse_rdf_files(f1,f2,er): From 52072acae316439d2808ba65ac286640490e85b5 Mon Sep 17 00:00:00 2001 From: Marvin Date: Tue, 14 Apr 2026 17:20:07 +0200 Subject: [PATCH 25/42] feat(eval): add ignored-entity filtering and intersecting-type alignment Extend entity alignment to support an ignored-entities set, add a new label-embedding + intersecting-type method, and update MovieKG eval integration tests (including multi-source pipeline permutations). --- .../src/moviekg/datasets/tmp_remove_seeds.py | 2 +- .../moviekg/evaluation/test_eval_refactor.py | 56 +++++++++++++++++-- src/kgpipe/datasets/multipart_multisource.py | 10 +++- src/kgpipe_eval/metrics/entity_alignment.py | 54 +++++++++++++++++- src/kgpipe_eval/utils/alignment_utils.py | 28 +++++++++- 5 files changed, 138 insertions(+), 12 deletions(-) diff --git a/experiments/moviekg/src/moviekg/datasets/tmp_remove_seeds.py b/experiments/moviekg/src/moviekg/datasets/tmp_remove_seeds.py index b29c0b7..ea3dded 100644 --- a/experiments/moviekg/src/moviekg/datasets/tmp_remove_seeds.py +++ b/experiments/moviekg/src/moviekg/datasets/tmp_remove_seeds.py @@ -7,7 +7,7 @@ import pandas as pd from pathlib import Path -bench_data = KgBenchData.from_path(Path("/home/marvin/phd/data/moviekg/datasets/film_10k")) +bench_data = KgBenchData.from_path(Path("/home/marvin/phd/data/moviekg/datasets/film_1k")) for i in range(1, 4): seed = bench_data.dataset.splits[f"split_{0}"].kg_reference.meta.entities.file diff --git a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py index 3fdee2f..f7b739e 100644 --- a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py +++ b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py @@ -18,11 +18,17 @@ from kgpipe.common.model.data import DataFormat import json from dataclasses import asdict +from itertools import permutations +from typing import Set +from kgpipe_eval.utils.kg_utils import Term try: from moviekg import config as moviekg_config + from moviekg.pipelines.test_inc_msp import ssp, idfn except Exception as e: # These are integration-style tests that depend on local env/config files. + import traceback + traceback.print_exc() pytest.skip(f"MovieKG config not available for eval integration test: {e}", allow_module_level=True) # TODO # [ ] Dataset Reader (split,ref,source,metadata) @@ -33,7 +39,7 @@ # substract seed from kg_1 and kg_1 from kg_2, or only seed from kg_1 and kg_2 -EX_BENCH_DATA_PATH = Path("/home/marvin/phd/data/moviekg/datasets/film_10k") +EX_BENCH_DATA_PATH = Path("/home/marvin/phd/data/moviekg/datasets/film_10k") # TODO read from env # EX_INC_PIPE_DATA_PATH = Path("/home/marvin/phd/data/moviekg/output/large/rdf_a") # TODO is a wrapper interface for now, Dataset needs refactor later @@ -52,6 +58,12 @@ def get_verified_entities_path(self, i: int, source_type: str) -> Path: current_new = current_path.with_name(f"{current_path.stem}_no_seed{current_path.suffix}") return current_new + def get_ignored_entities(self, i: int, source_type: str) -> Set[Term]: + seed_entities = self.dataset.splits[f"split_{0}"].kg_seed.meta.entities.read_csv() + # source_seed_entities = self.dataset.splits[f"split_{i-1}"].sources[source_type].meta.entities.read_csv() + return set([entity.entity_id for entity in seed_entities]) # + [entity.entity_id for entity in source_seed_entities]) + + class KgPipeData(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) result_kg: KgLike # name=rdf_a_1 @@ -76,17 +88,18 @@ def build_config_dict(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> dup_cfg = DuplicateConfig( entity_alignment_config=EntityAlignmentConfig( method="label_embedding", - verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="todo"), + verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data verified_entities_delimiter="\t", entity_sim_threshold=0.95, ) ) ent_cfg = EntityAlignmentConfig( - method="label_embedding_and_type", + method="label_embedding_and_intersecting_type", verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data verified_entities_delimiter="\t", - entity_sim_threshold=0.95 + entity_sim_threshold=0.95, + ignored_entities=bench_data.get_ignored_entities(i=i, source_type="rdf") # TODO type needs to be derived from pipe_data ) return { @@ -190,5 +203,40 @@ def test_evaluate_new(pipeline_name: str): print(f"Wrote results to {stage_dir / 'eval_results.json'}") # Smoke checks: we got metric results back for this stage. + assert isinstance(results, list) + assert results + + +@pytest.mark.parametrize( + "source_1, source_2, source_3", + permutations(list[str](ssp.keys()), 3), + ids=idfn, +) +def test_evaluate_new_multisource_pipeline(source_1: str, source_2: str, source_3: str): + """ + Integration test for the *multi-source* incremental pipelines where the selected + source changes per iteration/stage (e.g. `a_b_c/stage_1`, `a_b_c/stage_2`, ...). + """ + pipeline_name = f"{source_1}_{source_2}_{source_3}" + output_dir = moviekg_config.OUTPUT_ROOT / pipeline_name + + if not output_dir.exists(): + pytest.skip(f"Pipeline output directory {output_dir} not found") + + stage_dirs = _stage_dirs(output_dir) + if not stage_dirs: + pytest.skip(f"No stage directories found under {output_dir}") + + bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) + + for stage_dir in stage_dirs: + i = int(stage_dir.name.split("_", 1)[1]) + pipe_data = KgPipeData.from_path(stage_dir) + results = evaluate_stage(i=i, pipe_data=pipe_data, bench_data=bench_data) + + eval_results = _metric_results_to_jsonable(results) + with open(stage_dir / "eval_results.json", "w") as f: + json.dump(eval_results, f, indent=2) + assert isinstance(results, list) assert results \ No newline at end of file diff --git a/src/kgpipe/datasets/multipart_multisource.py b/src/kgpipe/datasets/multipart_multisource.py index 22f1d93..6653b06 100644 --- a/src/kgpipe/datasets/multipart_multisource.py +++ b/src/kgpipe/datasets/multipart_multisource.py @@ -215,13 +215,15 @@ class SplitIndex(BaseModel): # raise ValueError(f"{self.entities_csv} must contain an 'entity_id' column; got {header}") # return self +# SourceType = Literal["rdf", "json", "text"] + class Split(BaseModel): split_id: str root: Path index: SplitIndex kg_reference: Optional[KGBundle] = None kg_seed: Optional[KGBundle] = None - sources: Dict[str, SourceBundle] + sources: Dict[str, SourceBundle] # TODO SourceType def set_index(self, entities: List[EntitiesRow]): self.index.dir.mkdir(parents=True, exist_ok=True) @@ -548,12 +550,16 @@ def load_dataset(root: Path) -> Dataset: if seed_dir.exists(): seed_data_dir = seed_dir / "data" seed_meta_dir = seed_dir / "meta" + seed_meta = SourceMeta(root=seed_meta_dir) + ve = seed_meta_dir / "verified_entities.csv" + if ve.exists(): + seed_meta.entities = VerifiedEntities(file=ve) seed_parts = list_parts(seed_data_dir, (".nt", ".ttl", ".nq")) kg_seed = KGBundle( kind="seed", root=seed_dir, data=SourceData(dir=seed_data_dir, parts=seed_parts), - meta=SourceMeta(root=seed_meta_dir) + meta=seed_meta ) # sources diff --git a/src/kgpipe_eval/metrics/entity_alignment.py b/src/kgpipe_eval/metrics/entity_alignment.py index 3d9c0b6..d6237cc 100644 --- a/src/kgpipe_eval/metrics/entity_alignment.py +++ b/src/kgpipe_eval/metrics/entity_alignment.py @@ -3,7 +3,7 @@ from kgpipe_eval.api import Metric, Measurement, MetricResult from kgpipe_eval.utils.measurement_utils import BCMeasurement -from kgpipe_eval.utils.alignment_utils import align_entities_by_label_embedding, EntityAlignmentConfig, load_entity_uri_label_type_pairs, get_entity_uri_label_type_pairs +from kgpipe_eval.utils.alignment_utils import align_entities_by_label_embedding, EntityAlignmentConfig, load_entity_uri_label_type_pairs, get_entity_uri_label_typeset_pairs, get_entity_uri_label_type_pairs # Core Interface @@ -14,6 +14,8 @@ def eval_entity_alignment(kg: KG, config: EntityAlignmentConfig): alignments = eval_entity_alignment_by_label_alias_embedding(kg, config) elif config.method == "label_embedding_and_type": alignments = eval_entity_alignment_by_label_embedding_and_type(kg, config) + elif config.method == "label_embedding_and_intersecting_type": + alignments = eval_entity_alignment_by_label_embedding_and_intersecting_type(kg, config) else: raise ValueError(f"Invalid method: {config.method}") return alignments @@ -24,7 +26,7 @@ def eval_entity_alignment_by_label_embedding_and_type(kg: KG, config: EntityAlig alignments = align_entities_by_label_embedding(kg, config) ref_entity_uri_label_type_pairs = load_entity_uri_label_type_pairs(config) - gen_entity_uri_label_type_pairs = list(get_entity_uri_label_type_pairs(kg)) + gen_entity_uri_label_type_pairs = list(get_entity_uri_label_type_pairs(kg, config.ignored_entities)) # print ref and gen pairs for testing # print("--------------------------------") @@ -67,6 +69,54 @@ def eval_entity_alignment_by_label_embedding_and_type(kg: KG, config: EntityAlig fn=fn ) +def eval_entity_alignment_by_label_embedding_and_intersecting_type(kg: KG, config: EntityAlignmentConfig): + # Debugging: print some information about the config + print("--------------------------------") + print("ignored_entities") + print(len(config.ignored_entities)) + print("--------------------------------") + + alignments = align_entities_by_label_embedding(kg, config) + + ref_entity_uri_label_type_pairs = load_entity_uri_label_type_pairs(config) + gen_entity_uri_label_type_pairs = list(get_entity_uri_label_typeset_pairs(kg, config.ignored_entities)) + + ref_types = {pair.uri: set([pair.type]) for pair in ref_entity_uri_label_type_pairs if pair.type is not None} + # TODO gen_types can be multiple types, we need to handle this + gen_types = {pair.uri: pair.type_set for pair in gen_entity_uri_label_type_pairs if pair.type_set is not None} + + filtered_alignments = [] + for alignment in alignments: + if alignment.target in ref_types and alignment.source in gen_types: + # Debugging: print the intersection of the reference and generated types + # print("---") + # print("alignment.target", alignment.target) + # print("alignment.source", alignment.source) + # print("ref_types[alignment.target]", ref_types[alignment.target]) + # print("gen_types[alignment.source]", gen_types[alignment.source]) + # print("intersection", ref_types[alignment.target] & gen_types[alignment.source]) + # print("---") + if len(ref_types[alignment.target] & gen_types[alignment.source]) > 0: + filtered_alignments.append(alignment) + + ref_uris = set(pair.uri for pair in ref_entity_uri_label_type_pairs) + gen_uris = set(pair.uri for pair in gen_entity_uri_label_type_pairs) + aligned_gen_uris = set(alignment.target for alignment in filtered_alignments) + aligned_ref_uris = set(alignment.source for alignment in filtered_alignments) + + tp = len(ref_uris & aligned_gen_uris) # generated entities that are also in the reference + fp = len(gen_uris - aligned_ref_uris) # generated entities that are not in the reference + tn = 0 + fn = len(ref_uris - aligned_gen_uris) # missing generated entities that are in the reference + + return BCMeasurement( + tp=tp, + fp=fp, + tn=tn, + fn=fn + ) + + def eval_entity_alignment_by_label_embedding(kg: KG, config: EntityAlignmentConfig): alignments = align_entities_by_label_embedding(kg, config) diff --git a/src/kgpipe_eval/utils/alignment_utils.py b/src/kgpipe_eval/utils/alignment_utils.py index 80546a4..be87a96 100644 --- a/src/kgpipe_eval/utils/alignment_utils.py +++ b/src/kgpipe_eval/utils/alignment_utils.py @@ -10,16 +10,18 @@ from kgpipe.datasets.multipart_multisource import read_entities_csv, EntitiesRow import numpy as np from pathlib import Path +from typing import Set # TODO source entities csv to label only graph class EntityAlignmentConfig(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - method: Literal["label_embedding", "label_alias_embedding", "label_embedding_and_type"] = "label_embedding" + method: Literal["label_embedding", "label_alias_embedding", "label_embedding_and_type", "label_embedding_and_intersecting_type"] = "label_embedding" reference_kg: Optional[KgLike] = None verified_entities_path: Optional[Path] = None verified_entities_delimiter: str = "\t" entity_sim_threshold: float = 0.95 + ignored_entities: Optional[Set[Term]] = None # value_sim_threshold: float = 0.5 @@ -48,8 +50,9 @@ def get_aligned_triples(kg: KG, reference_kg: KG, method: Literal["exact", "fuzz # return [(s, label) for s, _, label in triple_graph.triples((None, RDFS.label, None))] UriLabelTypePair = NamedTuple("UriLabelTypePair", [("uri", Term), ("label", Term), ("type", Term)]) +UriLabelTypeSetPair = NamedTuple("UriLabelTypeSetPair", [("uri", Term), ("label", Term), ("type_set", set[Term])]) -def get_entity_uri_label_type_pairs(kg: KG) -> list[UriLabelTypePair]: +def get_entity_uri_label_type_pairs(kg: KG, ignored_entities: Optional[Set[Term]] = None) -> list[UriLabelTypePair]: label_by_uri = {} type_by_uri = {} for s, p, o in kg.triples((None, RDFS.label, None)): @@ -57,11 +60,30 @@ def get_entity_uri_label_type_pairs(kg: KG) -> list[UriLabelTypePair]: for s, p, o in kg.triples((None, RDF.type, None)): type_by_uri[str(s)] = str(o) for uri in label_by_uri: + if ignored_entities and str(uri) in ignored_entities: + continue if uri in type_by_uri: yield UriLabelTypePair(uri=uri, label=label_by_uri[uri], type=type_by_uri[uri]) else: yield UriLabelTypePair(uri=uri, label=label_by_uri[uri], type=None) +def get_entity_uri_label_typeset_pairs(kg: KG, ignored_entities: Optional[Set[Term]] = None) -> list[UriLabelTypeSetPair]: + label_by_uri = {} + types_by_uri = {} + for s, p, o in kg.triples((None, RDFS.label, None)): + label_by_uri[str(s)] = str(o) + for s, p, o in kg.triples((None, RDF.type, None)): + if str(s) not in types_by_uri: + types_by_uri[str(s)] = set() + types_by_uri[str(s)].add(str(o)) + for uri in label_by_uri: + if ignored_entities and str(uri) in ignored_entities: + continue + if uri in types_by_uri: + yield UriLabelTypeSetPair(uri=uri, label=label_by_uri[uri], type_set=types_by_uri[uri]) + else: + yield UriLabelTypeSetPair(uri=uri, label=label_by_uri[uri], type_set=set()) + def load_verified_entities(path: Path, delimiter: str = "\t") -> list[UriLabelTypePair]: """ """ @@ -89,7 +111,7 @@ def align_entities_by_label_embedding(tg: TripleGraph, config: EntityAlignmentCo ref_labels = [pair.label for pair in ref_entity_uri_label_type_pairs] ref_labels_embeddings = model.encode(ref_labels, convert_to_numpy=True, show_progress_bar=False) - gen_entity_uri_label_type_pairs = list(get_entity_uri_label_type_pairs(tg)) + gen_entity_uri_label_type_pairs = list(get_entity_uri_label_type_pairs(tg, config.ignored_entities)) gen_labels = [pair.label for pair in gen_entity_uri_label_type_pairs] gen_labels_embeddings = model.encode(gen_labels, convert_to_numpy=True, show_progress_bar=False) From ea02c1525ea2baccf03ae6e907399881cfe8e36e Mon Sep 17 00:00:00 2001 From: marvin Date: Thu, 16 Apr 2026 18:44:55 +0200 Subject: [PATCH 26/42] stash --- .../src/param_opti/pipeline_util.py | 5 ++ .../param-opti/src/param_opti/search.py | 17 ++++++ .../src/param_opti/tasks/base_linker.py | 22 ++++++++ .../src/param_opti/tasks/base_matcher.py | 46 ++++++++++++++++ .../src/param_opti/tasks/formats.py | 8 +++ .../param-opti/src/param_opti/tasks/fusion.py | 0 .../param-opti/src/param_opti/tasks/paris.py | 0 .../src/param_opti/tasks/spotlight.py | 0 pyproject.toml | 52 ++++++++++++++++++- 9 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 experiments/param-opti/src/param_opti/pipeline_util.py create mode 100644 experiments/param-opti/src/param_opti/search.py create mode 100644 experiments/param-opti/src/param_opti/tasks/base_linker.py create mode 100644 experiments/param-opti/src/param_opti/tasks/base_matcher.py create mode 100644 experiments/param-opti/src/param_opti/tasks/formats.py create mode 100644 experiments/param-opti/src/param_opti/tasks/fusion.py create mode 100644 experiments/param-opti/src/param_opti/tasks/paris.py create mode 100644 experiments/param-opti/src/param_opti/tasks/spotlight.py diff --git a/experiments/param-opti/src/param_opti/pipeline_util.py b/experiments/param-opti/src/param_opti/pipeline_util.py new file mode 100644 index 0000000..eb0acf3 --- /dev/null +++ b/experiments/param-opti/src/param_opti/pipeline_util.py @@ -0,0 +1,5 @@ + + + +# check current implementation state + diff --git a/experiments/param-opti/src/param_opti/search.py b/experiments/param-opti/src/param_opti/search.py new file mode 100644 index 0000000..82658f7 --- /dev/null +++ b/experiments/param-opti/src/param_opti/search.py @@ -0,0 +1,17 @@ + + +def sample_random_valid(task_impls: List[str]): + pass + +class SearchSpace: + def __init__(self, task_impls: List[str]): + self.task_impls = task_impls + +class NeighborhoodSearch: + def __init__(self, search_space: SearchSpace): + self.search_space = search_space + + def search(self, budget: int): + pass + + diff --git a/experiments/param-opti/src/param_opti/tasks/base_linker.py b/experiments/param-opti/src/param_opti/tasks/base_linker.py new file mode 100644 index 0000000..7c8c10f --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/base_linker.py @@ -0,0 +1,22 @@ +from kgpipe.common import TaskInput, TaskOutput, Data, DataFormat + +def relation_linker_label_alias_embedding_transformer(inputs: TaskInput, outputs: TaskOutput): + """ + Link relations using a base transformer model. + """ + pass + # relation_text = inputs["relation_text"] + # relation_linker = RelationLinkerBaseTransformer(relation_text) + # relation_linker.link() + # outputs["relation_link"] = relation_linker.relation_link + + +def entity_linker_label_alias_embedding_transformer(inputs: TaskInput, outputs: TaskOutput): + """ + Link entities using a base transformer model. + """ + pass + # entity_text = inputs["entity_text"] + # entity_linker = EntityLinkerBaseTransformer(entity_text) + # entity_linker.link() + # outputs["entity_link"] = entity_linker.entity_link \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/base_matcher.py b/experiments/param-opti/src/param_opti/tasks/base_matcher.py new file mode 100644 index 0000000..d70e7ac --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/base_matcher.py @@ -0,0 +1,46 @@ +from kgpipe.common import TaskInput, TaskOutput, Data, DataFormat, Registry, BasicTaskCategoryCatalog +from kgpipe.common.model.configuration import ConfigurationDefinition, Parameter, ParameterType + +@Registry.task( + input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, + output_spec={"output": DataFormat.AGREEMENTMAKER_RDF}, + description="Perform entity matching using AgreementMaker", + category=[BasicTaskCategoryCatalog.entity_matching], + config_spec=ConfigurationDefinition( + parameters=[ + Parameter(name="model_name", type=ParameterType.STRING, default="sentence-transformers/all-MiniLM-L6-v2"), + Parameter(name="similarity_threshold", type=ParameterType.NUMBER, default=0.5), + ] + ) +) +def relation_matcher_label_alias_embedding_transformer(inputs: TaskInput, outputs: TaskOutput): + """ + Match relations using a base transformer model. + """ + pass + # relation_text = inputs["relation_text"] + # relation_matcher = RelationMatcherBaseTransformer(relation_text) + # relation_matcher.match() + # outputs["relation_matcher"] = relation_matcher.relation_matcher + +@Registry.task( + input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, + output_spec={"output": DataFormat.AGREEMENTMAKER_RDF}, + description="Perform entity matching using AgreementMaker", + category=[BasicTaskCategoryCatalog.entity_matching], + config_spec=ConfigurationDefinition( + parameters=[ + Parameter(name="model_name", type=ParameterType.STRING, default="sentence-transformers/all-MiniLM-L6-v2"), + Parameter(name="similarity_threshold", type=ParameterType.NUMBER, default=0.5), + ] + ) +) +def entity_matcher_label_alias_embedding_transformer(inputs: TaskInput, outputs: TaskOutput): + """ + Match entities using a base transformer model. + """ + pass + # entity_text = inputs["entity_text"] + # entity_matcher = EntityMatcherBaseTransformer(entity_text) + # entity_matcher.match() + # outputs["entity_matcher"] = entity_matcher.entity_matcher \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/formats.py b/experiments/param-opti/src/param_opti/tasks/formats.py new file mode 100644 index 0000000..97d1a3e --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/formats.py @@ -0,0 +1,8 @@ + +# reimport and define of used formats + +from kgpipe.common import DataFormat +from kgpipe.common.model.default_catalog import BasicDataFormats, CustomDataFormats + +class ExtendedFormats(CustomDataFormats): + pass \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/fusion.py b/experiments/param-opti/src/param_opti/tasks/fusion.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/param_opti/tasks/paris.py b/experiments/param-opti/src/param_opti/tasks/paris.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/param_opti/tasks/spotlight.py b/experiments/param-opti/src/param_opti/tasks/spotlight.py new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 8c4e40b..57a7d1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,8 @@ dependencies = [ "rdflib>=6.0.0", "matplotlib>=3.5.0", "networkx>=2.8.0", - "transformers>=4.50.0", - "sentence_transformers>=4.1.0", + # ML stack (torch/transformers) is intentionally NOT in base deps. + # Install via `pip/uv pip install ".[ml]"` plus the desired torch index (CPU/CUDA). "pulp>=3.3.0", "pytest>=8.4.2", "dotenv>=0.9.9", @@ -39,6 +39,54 @@ dependencies = [ [project.optional-dependencies] dev = ["pytest", "pytest-mock", "pytest-cov", "ruff", "black"] +cpu = [ + "torch", + "torchvision", + "torchaudio", +] +cuda = [ + "torch", + "torchvision", + "torchaudio", +] +ml = [ + "transformers>=4.50.0", + "sentence_transformers>=4.1.0", +] + +[tool.uv] +conflicts = [ + [ + { extra = "cpu" }, + { extra = "cuda" }, + ], +] + +[tool.uv.sources] +torch = [ + { index = "pytorch-cpu", extra = "cpu" }, + { index = "pytorch-cuda", extra = "cuda" }, +] +torchvision = [ + { index = "pytorch-cpu", extra = "cpu" }, + { index = "pytorch-cuda", extra = "cuda" }, +] +torchaudio = [ + { index = "pytorch-cpu", extra = "cpu" }, + { index = "pytorch-cuda", extra = "cuda" }, +] + +[[tool.uv.index]] +name = "pytorch-cpu" +url = "https://download.pytorch.org/whl/cpu" +explicit = true + +# CUDA wheels live on a separate PyTorch index. +# If you need a different CUDA version, change the URL (e.g. `cu128`, `cu126`, `cu121`). +[[tool.uv.index]] +name = "pytorch-cuda" +url = "https://download.pytorch.org/whl/cu130" +explicit = true [tool.setuptools.packages.find] where = ["src"] From 1ff7cbd461909b121d378eaaf1a11a9171079cb9 Mon Sep 17 00:00:00 2001 From: marvin Date: Thu, 16 Apr 2026 18:45:08 +0200 Subject: [PATCH 27/42] stash --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 54fcba9..2900b0a 100644 --- a/README.md +++ b/README.md @@ -51,5 +51,29 @@ KGpipe provides Single-Source Pipelines (SSPs) and Multi-Source Pipelines (MSPs) For documentation see the [docs](docs/reproduce.md) +## Installation notes (CPU vs CUDA) + +Some optional ML dependencies (e.g. `sentence_transformers`) pull in PyTorch (`torch`). Depending on which PyTorch wheel gets selected, you may see large downloads like `nvidia-*` and `triton`. + +KGpipe keeps the ML stack out of the default install; install it explicitly when needed. For `uv`, PyTorch is pinned to the official PyTorch wheel indexes to avoid accidentally pulling CUDA wheels from PyPI. + +### Base install (fast, no torch) + +```bash +uv pip install . +``` + +### ML install with CPU-only PyTorch (no `nvidia-*`) + +```bash +uv pip install ".[ml,cpu]" +``` + +### ML install with CUDA-enabled PyTorch (will download `nvidia-*`) + +```bash +uv pip install ".[ml,cuda]" +``` + ## Experiments - **[moviekg](experiments/moviekg/README.md)** evalaution of a pipelines, building a Movie KG from three sources (rdf,json,text). From 5ff6c956e6d0651a9512c82c33e03c81855c254a Mon Sep 17 00:00:00 2001 From: marvin Date: Tue, 21 Apr 2026 11:55:14 +0200 Subject: [PATCH 28/42] exp(params): draft config experiments; KgPipe consume configs --- .../src/param_opti/tasks/__init__.py | 4 + .../src/param_opti/tasks/base_linker.py | 37 ++- .../param-opti/src/param_opti/tasks/fusion.py | 24 ++ .../param-opti/src/param_opti/tasks/jedai.py | 0 .../param-opti/src/param_opti/tasks/paris.py | 46 ++++ experiments/param-opti/src/qap/__init__.py | 0 .../src/qap/test_pipeline_config.py | 232 ++++++++++++++++++ .../param-opti/src/qap/test_ref_based.py | 28 +++ src/kgpipe/common/model/pipeline.py | 102 +++++++- src/kgpipe/common/registry.py | 4 + 10 files changed, 462 insertions(+), 15 deletions(-) create mode 100644 experiments/param-opti/src/param_opti/tasks/jedai.py create mode 100644 experiments/param-opti/src/qap/__init__.py create mode 100644 experiments/param-opti/src/qap/test_pipeline_config.py create mode 100644 experiments/param-opti/src/qap/test_ref_based.py diff --git a/experiments/param-opti/src/param_opti/tasks/__init__.py b/experiments/param-opti/src/param_opti/tasks/__init__.py index e69de29..bb421c4 100644 --- a/experiments/param-opti/src/param_opti/tasks/__init__.py +++ b/experiments/param-opti/src/param_opti/tasks/__init__.py @@ -0,0 +1,4 @@ +from .paris import paris_entity_alignment_task, paris_graph_alignment_task +from .fusion import fusion_first_value_task, fusion_union_task + +__all__ = ["paris_entity_matching_task", "paris_exchange_task", "fusion_first_value_task", "fusion_union_task"] \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/base_linker.py b/experiments/param-opti/src/param_opti/tasks/base_linker.py index 7c8c10f..5af3df4 100644 --- a/experiments/param-opti/src/param_opti/tasks/base_linker.py +++ b/experiments/param-opti/src/param_opti/tasks/base_linker.py @@ -1,6 +1,7 @@ -from kgpipe.common import TaskInput, TaskOutput, Data, DataFormat +from kgpipe.common import TaskInput, TaskOutput, Data, DataFormat, KgTask +from kgpipe.common.model.configuration import ConfigurationProfile, ConfigurationDefinition, Parameter, ParameterType -def relation_linker_label_alias_embedding_transformer(inputs: TaskInput, outputs: TaskOutput): +def relation_linker_label_alias_embedding_transformer_function(inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile): """ Link relations using a base transformer model. """ @@ -10,8 +11,21 @@ def relation_linker_label_alias_embedding_transformer(inputs: TaskInput, outputs # relation_linker.link() # outputs["relation_link"] = relation_linker.relation_link +relation_linker_label_alias_embedding_transformer_task = KgTask( + name="relation_linker_label_alias_embedding_transformer", + function=relation_linker_label_alias_embedding_transformer_function, + input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, + output_spec={"output": DataFormat.RDF}, + config_spec=ConfigurationDefinition( + name="relation_linker_label_alias_embedding_transformer", + parameters=[ + Parameter(name="model_name", native_keys=["--model-name"], datatype=ParameterType.string, default_value="sentence-transformers/all-MiniLM-L6-v2", required=True, allowed_values=["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"]), + Parameter(name="similarity_threshold", native_keys=["--similarity-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), + ] + ) +) -def entity_linker_label_alias_embedding_transformer(inputs: TaskInput, outputs: TaskOutput): +def entity_linker_label_alias_embedding_transformer_function(inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile): """ Link entities using a base transformer model. """ @@ -19,4 +33,19 @@ def entity_linker_label_alias_embedding_transformer(inputs: TaskInput, outputs: # entity_text = inputs["entity_text"] # entity_linker = EntityLinkerBaseTransformer(entity_text) # entity_linker.link() - # outputs["entity_link"] = entity_linker.entity_link \ No newline at end of file + # outputs["entity_link"] = entity_linker.entity_link + +entity_linker_label_alias_embedding_transformer_task = KgTask( + name="entity_linker_label_alias_embedding_transformer", + function=entity_linker_label_alias_embedding_transformer_function, + input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, + output_spec={"output": DataFormat.RDF}, + config_spec=ConfigurationDefinition( + name="entity_linker_label_alias_embedding_transformer", + parameters=[ + Parameter(name="model_name", native_keys=["--model-name"], datatype=ParameterType.string, default_value="sentence-transformers/all-MiniLM-L6-v2", required=True, allowed_values=["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"]), + Parameter(name="similarity_threshold", native_keys=["--similarity-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), + ] + ) +) + diff --git a/experiments/param-opti/src/param_opti/tasks/fusion.py b/experiments/param-opti/src/param_opti/tasks/fusion.py index e69de29..ee73e29 100644 --- a/experiments/param-opti/src/param_opti/tasks/fusion.py +++ b/experiments/param-opti/src/param_opti/tasks/fusion.py @@ -0,0 +1,24 @@ +from kgpipe.common.model.configuration import ConfigurationProfile +from kgpipe.common.models import TaskInput, TaskOutput, KgTask, DataFormat + +def fusion_first_value_function(inputs: TaskInput, outputs: TaskOutput): + # touch output file + outputs["output"].path.touch() + +fusion_first_value_task = KgTask( + name="fusion_first_value", + function=fusion_first_value_function, + input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES, "matches": DataFormat.ER_JSON}, + output_spec={"output": DataFormat.RDF_NTRIPLES}, +) + +def fusion_union_function(inputs: TaskInput, outputs: TaskOutput): + # touch output file + outputs["output"].path.touch() + +fusion_union_task = KgTask( + name="fusion_union", + function=fusion_union_function, + input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES, "matches": DataFormat.ER_JSON}, + output_spec={"output": DataFormat.RDF_NTRIPLES}, +) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/jedai.py b/experiments/param-opti/src/param_opti/tasks/jedai.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/param_opti/tasks/paris.py b/experiments/param-opti/src/param_opti/tasks/paris.py index e69de29..21dbce9 100644 --- a/experiments/param-opti/src/param_opti/tasks/paris.py +++ b/experiments/param-opti/src/param_opti/tasks/paris.py @@ -0,0 +1,46 @@ +from kgpipe.common import TaskInput, TaskOutput, KgTask, DataFormat +from kgpipe.common.model.configuration import ConfigurationProfile, ConfigurationDefinition, Parameter, ParameterType + + + +def paris_entity_alignment_function(inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile): + """ + matches entities between two RDF graphs + """ + # touch output file + print(f"paris_entity_alignment_function: {outputs['output'].path}") + outputs["output"].path.touch() + +paris_entity_alignment_task = KgTask( + name="paris_entity_alignment", + function=paris_entity_alignment_function, + input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES}, + output_spec={"output": DataFormat.ER_JSON}, + config_spec=ConfigurationDefinition( + name="paris_entity_alignment", + parameters=[ + Parameter(name="entity_matching_threshold", native_keys=["--entity-matching-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), + ] + ) +) + +def paris_graph_alignment_function(inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile): + """ + matches both entities and relations between two RDF graphs + """ + # touch output file + outputs["output"].path.touch() + +paris_graph_alignment_task = KgTask( + name="paris_graph_alignment", + function=paris_graph_alignment_function, + input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES}, + output_spec={"output": DataFormat.ER_JSON}, + config_spec=ConfigurationDefinition( + name="paris_graph_alignment", + parameters=[ + Parameter(name="entity_matching_threshold", native_keys=["--entity-matching-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), + Parameter(name="relation_matching_threshold", native_keys=["--relation-matching-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), + ] + ) +) \ No newline at end of file diff --git a/experiments/param-opti/src/qap/__init__.py b/experiments/param-opti/src/qap/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/qap/test_pipeline_config.py b/experiments/param-opti/src/qap/test_pipeline_config.py new file mode 100644 index 0000000..e1e7c79 --- /dev/null +++ b/experiments/param-opti/src/qap/test_pipeline_config.py @@ -0,0 +1,232 @@ +from typing import List, Dict, Any, Optional +import random +from kgpipe.common import KgPipe, Data, DataFormat, Registry +from kgpipe.common.model.configuration import ConfigurationProfile, ParameterBinding +from kgpipe.common.model.task import KgTask +from pydantic import BaseModel +from param_opti.tasks.paris import paris_graph_alignment_task +from param_opti.tasks.fusion import fusion_first_value_task +from param_opti.tasks.base_linker import relation_linker_label_alias_embedding_transformer_task, entity_linker_label_alias_embedding_transformer_task +from kgpipe.generation.loaders import build_from_conf +from pathlib import Path +# for given tasks and config parameters, generate a pipeline (KGpipe) + +tmp_base_dir = Path("tmp") +if not tmp_base_dir.exists(): + tmp_base_dir.mkdir(parents=True, exist_ok=True) + + +class PipelineConfig(BaseModel): + tasks: List[KgTask] + config_catalog: Dict[str, ConfigurationProfile] + +SEARCH_SPACE = { + "paris_graph_alignment_task": { + "category": "entity_matching", + "entity_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + "relation_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "fusion_first_value_task": { + "category": "fusion", + # "fusion_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "relation_linker_label_alias_embedding_transformer_task": { + "category": "entity_linking", + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "entity_linker_label_alias_embedding_transformer_task": { + "category": "entity_linking", + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, +} + +task_dict = { + "paris_graph_alignment_task": paris_graph_alignment_task, + "fusion_first_value_task": fusion_first_value_task, + "relation_linker_label_alias_embedding_transformer_task": relation_linker_label_alias_embedding_transformer_task, + "entity_linker_label_alias_embedding_transformer_task": entity_linker_label_alias_embedding_transformer_task, +} + +for task_name, task in task_dict.items(): + Registry.add_task(task_name, task) + +class PipelineLayout(BaseModel): + """ + allowed task categories in the pipeline + """ + allowed_task_categories: List[str] + +def _get_param(definition: Any, param_name: str): + params = getattr(definition, "parameters", None) + if params is None: + raise KeyError(f"Task config_spec has no parameters field (missing {param_name})") + + # common shapes: dict-like or list of Parameter + if hasattr(params, "get"): + p = params.get(param_name) + if p is None: + raise KeyError(f"Parameter {param_name} not found in config_spec.parameters") + return p + + for p in params: + if getattr(p, "name", None) == param_name: + return p + raise KeyError(f"Parameter {param_name} not found in config_spec.parameters") + +def get_default_rdf_pipeline_config() -> PipelineConfig: + return PipelineConfig( + tasks=[ + paris_graph_alignment_task, + fusion_first_value_task, + ], + config_catalog={ + # Key must match KgTask.name because KgPipe delegates by task.name + "paris_graph_alignment": ConfigurationProfile( + name="paris_graph_alignment", + definition=paris_graph_alignment_task.config_spec, + bindings=[ + ParameterBinding(parameter=_get_param(paris_graph_alignment_task.config_spec, "entity_matching_threshold"), value=0.5), + ParameterBinding(parameter=_get_param(paris_graph_alignment_task.config_spec, "relation_matching_threshold"), value=0.5), + ], + ) + }, + ) + +# TODO rules for valid pipeline config: +def sample_valid_pipeline_config( + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, +) -> PipelineConfig: + """ + Randomly sample a valid pipeline config from the search space, + respecting the order of categories in the pipeline layout. + """ + tasks: List[KgTask] = [] + config_catalog: Dict[str, ConfigurationProfile] = {} + + for category in pipeline_layout.allowed_task_categories: + eligible_task_names = [ + tn for tn, space in search_space.items() if space.get("category") == category + ] + if not eligible_task_names: + continue + + task_key = random.choice(eligible_task_names) + task = task_dict[task_key] + tasks.append(task) + + # metadata only or task has no config spec + if getattr(task, "config_spec", None) is None: + continue + + bindings: List[ParameterBinding] = [] + name_parts: List[str] = [] + for config_name, config_values in search_space[task_key].items(): + if config_name == "category": + continue + if not isinstance(config_values, list): + raise TypeError( + f"Search space values must be lists; got {task_key}.{config_name}={type(config_values)}" + ) + if not config_values: + raise ValueError(f"Empty search space for {task_key}.{config_name}") + + config_value = random.choice(config_values) + name_parts.append(f"{config_name}={config_value}") + bindings.append( + ParameterBinding( + parameter=_get_param(task.config_spec, config_name), + value=config_value, + ) + ) + + if bindings: + config_catalog[task.name] = ConfigurationProfile( + name=f"{task.name}_" + ",".join(name_parts), + definition=task.config_spec, + bindings=bindings, + ) + + return PipelineConfig(tasks=tasks, config_catalog=config_catalog) + +def print_pipeline_config_short(pipeline_config: PipelineConfig): + """ + print the pipeline config in a short format + """ + print() + print("================") + for task in pipeline_config.tasks: + task_name = task.name + profile: Optional[ConfigurationProfile] = pipeline_config.config_catalog.get(task_name) + if profile is None: + print(f"- {task_name}") + continue + + parts: List[str] = [] + for binding in profile.bindings: + parts.append(f"{binding.parameter.name}={binding.value}") + params = ", ".join(parts) + print(f"- {task_name}({params})") + + + +def test_sample_valid_rdf_pipeline_config(): + pipeline_layout = PipelineLayout( + allowed_task_categories=["ontology_matching", "entity_matching", "fusion"] + ) + pipeline_config = sample_valid_pipeline_config(SEARCH_SPACE, pipeline_layout) + print_pipeline_config_short(pipeline_config) + +def test_sample_valid_text_pipeline_config(): + pipeline_layout = PipelineLayout( + allowed_task_categories=["information_extraction", "entity_linking", "fusion"] + ) + pipeline_config = sample_valid_pipeline_config(SEARCH_SPACE, pipeline_layout) + print_pipeline_config_short(pipeline_config) + + +def test_rdf_pipeline_from_default_config(): + pipeline_config = get_default_rdf_pipeline_config() + + seed_path = tmp_base_dir / "seed.nt" + source_path = tmp_base_dir / "source.nt" + result_path = tmp_base_dir / "result.nt" + + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=tmp_base_dir / "tasks_tmp", + name="test_pipeline") + + pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) + + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) + +def test_rdf_pipeline_from_config(): + pipeline_config = sample_valid_pipeline_config(SEARCH_SPACE, PipelineLayout(allowed_task_categories=["entity_matching", "fusion"])) + + seed_path = tmp_base_dir / "seed.nt" + source_path = tmp_base_dir / "source.nt" + result_path = tmp_base_dir / "result.nt" + + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=tmp_base_dir / "tasks_tmp", + name="test_pipeline") + + pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) + + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) \ No newline at end of file diff --git a/experiments/param-opti/src/qap/test_ref_based.py b/experiments/param-opti/src/qap/test_ref_based.py new file mode 100644 index 0000000..b4fb3c3 --- /dev/null +++ b/experiments/param-opti/src/qap/test_ref_based.py @@ -0,0 +1,28 @@ +from kgpipe.common import KgPipe, Data, DataFormat +from param_opti.tasks.paris import paris_entity_alignment_task, paris_graph_alignment_task +from param_opti.tasks.fusion import fusion_first_value_task, fusion_union_task +from pathlib import Path + +# Using ground truth + +# 1. execute PARIS pipeline, with different thresholds +# 2. evaluate the quality of the pipeline, with different thresholds + + +# - [ ] impl paris wrapper with exchange and threshold filter + +seed_path = Path("data/seed.nt") +pipe_result_dir_path = Path("data/pipe_result") + +def get_paris_pipeline(threshold: float): + name = f"paris_graph_alignment_task={threshold}_fusion_first_value_task" + + return KgPipe( + name=name, + tasks=[paris_graph_alignment_task, fusion_first_value_task], + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=pipe_result_dir_path / "tmp" + ) + +def test_paris_pipelines(): + pass \ No newline at end of file diff --git a/src/kgpipe/common/model/pipeline.py b/src/kgpipe/common/model/pipeline.py index 8420c02..3755519 100644 --- a/src/kgpipe/common/model/pipeline.py +++ b/src/kgpipe/common/model/pipeline.py @@ -1,6 +1,7 @@ import os import time import uuid +import hashlib from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime @@ -18,6 +19,7 @@ from .data import Data, DataFormat, DataSet, Format from .task import KgTask, KgTaskReport +from .configuration import ConfigurationProfile # from .kg import KG from kgpipe.common.annotations import kg_class from kgpipe.common.graph.systemgraph import PipeKG @@ -125,19 +127,71 @@ def add_data(self, data: Data) -> None: self.data.append(data) - def build(self, source: Data, result: Optional[Data] = None, stable_files: bool = False) -> KgPipePlan: + def build( + self, + source: Data, + result: Optional[Data] = None, + stable_files: bool = False, + configCatalog: Optional[Mapping[str, ConfigurationProfile]] = None, + ) -> KgPipePlan: """Generate the execution plan as a list of dictionaries.""" catalog = [source] + self.data calls: List[KgPipePlanStep] = [] - def gen_file_path(task: KgTask, format_spec: Format, prefix: str = "", suffix: str = ""): - if stable_files: + def _profile_fingerprint(profile: Optional[ConfigurationProfile]) -> str: + if profile is None: + return "" + # Make it stable regardless of binding order. + bindings = [] + for b in getattr(profile, "bindings", []) or []: + param = getattr(b, "parameter", None) + pname = getattr(param, "name", None) + if pname is None: + pname = str(param) + bindings.append((str(pname), b.value)) + bindings.sort(key=lambda kv: kv[0]) + payload = json.dumps( + {"definition": getattr(getattr(profile, "definition", None), "name", None), "bindings": bindings}, + sort_keys=True, + default=str, + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + def _chain_hash(prev_hash: str, task_name: str, profile: Optional[ConfigurationProfile]) -> str: + fp = _profile_fingerprint(profile) + payload = json.dumps({"prev": prev_hash, "task": task_name, "profile": fp}, sort_keys=True) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + prev_hash = "0" * 64 + + def gen_file_path( + *, + task: KgTask, + format_spec: Format, + prefix: str = "", + suffix: str = "", + task_hash: Optional[str] = None, + ) -> Path: + # Backwards-compatible: stable_files without configCatalog keeps the old deterministic names. + if stable_files and configCatalog is None: return Path(self.data_dir) / f"{prefix}{task.name}{suffix}.{format_spec.extension}" - else: - return Path(self.data_dir) / f"{prefix}{task.name}.{uuid4().hex}.{format_spec.extension}" + + # If configCatalog is provided, filenames must be deterministic based on the hash chain. + if configCatalog is not None and task_hash is not None: + short = task_hash[:12] + return Path(self.data_dir) / f"{prefix}{task.name}.{short}{suffix}.{format_spec.extension}" + + # Default behavior: unique filenames. + return Path(self.data_dir) / f"{prefix}{task.name}.{uuid4().hex}.{format_spec.extension}" for idx, task in enumerate(self.tasks): + task_hash: Optional[str] = None + if configCatalog is not None: + profile = configCatalog.get(task.name) + task_hash = _chain_hash(prev_hash, task.name, profile) + prev_hash = task_hash + # Match inputs inputs = [] for input_name, format_spec in task.input_spec.items(): @@ -159,7 +213,13 @@ def gen_file_path(task: KgTask, format_spec: Format, prefix: str = "", suffix: s break else: suffix = f"_{len(outputs)}" - output_path = gen_file_path(task, format_spec, prefix=f"{idx}_", suffix=suffix) + output_path = gen_file_path( + task=task, + format_spec=format_spec, + prefix=f"{idx}_", + suffix=suffix, + task_hash=task_hash, + ) output_data = Data(path=output_path, format=format_spec) outputs.append(output_data) @@ -167,17 +227,19 @@ def gen_file_path(task: KgTask, format_spec: Format, prefix: str = "", suffix: s if len(inputs) != len(task.input_spec): missing_inputs = len(task.input_spec) - len(inputs) + catalog_str = "\n".join([str(i) for i in catalog]) raise ValueError( f"For task {task.name}: expected {task.input_spec} inputs, got {inputs}. " f"Missing {missing_inputs} inputs." - f"catalog: {"\n".join([str(i) for i in catalog])}" + f"catalog: {catalog_str}" ) elif len(outputs) != len(task.output_spec): missing_outputs = len(task.output_spec) - len(outputs) + catalog_str = "\n".join([str(i) for i in catalog]) raise ValueError( f"\nFor task {task.name}: expected {task.output_spec} outputs, got {outputs}. " f"\nMissing {missing_outputs} outputs." - f"\nCatalog: {"\n".join([str(i) for i in catalog])}" + f"\nCatalog: {catalog_str}" ) else: print(f"Adding task '{task.name}' to plan with\n\t inputs: {[str(i.path) for i in inputs]} and \n\t outputs: {[str(o.path) for o in outputs]}") @@ -211,7 +273,11 @@ def plot(self) -> None: """Plot the pipeline.""" pass - def run(self, stable_files_override: bool = False) -> List[KgTaskReport]: + def run( + self, + stable_files_override: bool = False, + configCatalog: Optional[Mapping[str, ConfigurationProfile]] = None, + ) -> List[KgTaskReport]: """Execute each task defined in the plan and collect the reports.""" if not self.plan: raise ValueError("Pipeline plan is empty. Call build() first.") @@ -232,10 +298,24 @@ def run(self, stable_files_override: bool = False) -> List[KgTaskReport]: if not input_data.exists(): raise FileNotFoundError(f"Input file {input_data.path} does not exist") + configProfile = None + if configCatalog is not None: + configProfile = configCatalog.get(task.name) + if self.previous_was_skipped: - report = task.run(task_spec.input, task_spec.output, stable_files_override=stable_files_override) + report = task.run( + task_spec.input, + task_spec.output, + stable_files_override=stable_files_override, + configProfile=configProfile, + ) else: - report = task.run(task_spec.input, task_spec.output, stable_files_override=True) + report = task.run( + task_spec.input, + task_spec.output, + stable_files_override=True, + configProfile=configProfile, + ) if report.status != "skipped": self.previous_was_skipped = False diff --git a/src/kgpipe/common/registry.py b/src/kgpipe/common/registry.py index f9d24e0..18bdcbf 100644 --- a/src/kgpipe/common/registry.py +++ b/src/kgpipe/common/registry.py @@ -59,6 +59,10 @@ def decorator(t): # Task # + @classmethod + def add_task(cls, name: str, task: KgTask): + cls._registry[f"task:{task.name}"] = task + @classmethod def task( cls, From 719afd0bec51921ad8a6c737fbef8dc6afa1873d Mon Sep 17 00:00:00 2001 From: Marvin Date: Sat, 25 Apr 2026 17:32:37 +0200 Subject: [PATCH 29/42] feat(llm): added first version of any_extract llm task --- src/kgpipe_llm/any_extraction.py | 198 +++++++++++++++++++++ src/kgpipe_llm/test/test_any_extraction.py | 24 +++ 2 files changed, 222 insertions(+) create mode 100644 src/kgpipe_llm/any_extraction.py create mode 100644 src/kgpipe_llm/test/test_any_extraction.py diff --git a/src/kgpipe_llm/any_extraction.py b/src/kgpipe_llm/any_extraction.py new file mode 100644 index 0000000..9509030 --- /dev/null +++ b/src/kgpipe_llm/any_extraction.py @@ -0,0 +1,198 @@ +# Generalized variant of RDF triple generation + +from kgpipe.common import Registry, DataFormat, Data, TaskInput, TaskOutput +from kgpipe.common.model.configuration import ConfigurationDefinition, Parameter, ParameterType, ConfigurationProfile +from kgpipe_llm.common.snippets import generate_ontology_snippet_v3 +from kgcore.api.ontology import OntologyUtil +from pathlib import Path +from kgpipe_llm.common.core import LLMClient + +from shutil import RegistryError +from pydantic import BaseModel, AnyUrl + +# class OntologyGroundedSurfaceTriple(BaseModel): +# subject_label: str +# predicate_uri: AnyUrl +# object_label: str + +from pydantic import BaseModel, Field, AnyUrl + + +class SurfaceTriple(BaseModel): + subject: str = Field( + description="Surface-form subject label. Not a URI." + ) + predicate_uri: AnyUrl = Field( + description="Ontology property URI." + ) + object: str = Field( + description="Surface-form object label or literal. Not a URI." + ) + + +class SurfaceTripleExtractionResult(BaseModel): + triples: list[SurfaceTriple] + +# ontology-guided semantic triple extraction. +# surface semantic triples +# ontology-grounded surface triples + + +def get_ontology_grounded_surface_triples_prompt_template(ontology: str, input_data: str) -> str: + return """ +You are an ontology-guided semantic triple extraction system. + +Your task is to extract ontology-grounded surface triples from the provided input data. + +A valid triple has the form: + + + +Where: +- subject is a surface-form string, label, name, or textual identifier. +- predicate_uri is a URI from the provided ontology vocabulary. +- object is a surface-form string, label, value, literal, or textual identifier. +- subject and object MUST NOT be converted into URIs. +- predicate_uri MUST be selected only from the ontology vocabulary. +- Do not invent ontology properties. +- Do not invent facts not supported by the input. +- Prefer the most specific ontology property that correctly matches the input. +- If a relation or attribute is present in the input but cannot be mapped to the ontology, place it in unmapped_candidates. +- Preserve meaningful entity names as they appear in the input, normalizing only whitespace and obvious formatting artifacts. +- Extract both attributes and relations when they can be represented with an ontology property. +- Return only valid structured output matching the provided schema. + +Ontology vocabulary: + +{ontology} + +Input data: + +{input_data} + +Extraction guidance: +1. Identify named entities, records, rows, objects, or document subjects. +2. Identify attributes and relations expressed in the input. +3. Map each attribute or relation to the best matching ontology property URI. +4. Emit triples using string labels for subject and object. +5. Include evidence when possible. +6. Include confidence between 0.0 and 1.0. +7. Report unmapped relation or attribute candidates. +""".format(ontology=ontology, input_data=input_data) + +def extract_ontology_surface_triples(data: str, ontology: Path, client: LLMClient) -> SurfaceTripleExtractionResult: + + ontology_snippet = generate_ontology_snippet_v3(OntologyUtil.load_ontology_from_file(ontology)) + + prompt = get_ontology_grounded_surface_triples_prompt_template(ontology_snippet, data) + response = client.send_prompt(prompt, SurfaceTripleExtractionResult) + + return response + +@Registry.task( + input_spec={"input": DataFormat.ANY}, + output_spec={"output": DataFormat.RDF_NTRIPLES}, + description="Generate RDF triples for a schema", + config_spec=ConfigurationDefinition( + name="extract_ontology_surface_triples", + parameters=[ + Parameter( + name="ontology", + datatype=ParameterType.string, + description="The schema to generate RDF triples for" + ), + Parameter( + name="prompt_template", + datatype=ParameterType.string, + description="The prompt template to use for the LLM" + ), + ] + ) +) +def extract_ontology_surface_triples_task(input: TaskInput, output: TaskOutput, config: ConfigurationProfile): + pass + + + +# from typing import Any, Literal +# from pydantic import BaseModel, Field, AnyUrl + + +# class OntologyTerm(BaseModel): +# uri: AnyUrl = Field( +# description="The ontology URI identifying a class, attribute, or relation." +# ) +# label: str | None = Field( +# default=None, +# description="Optional human-readable label for the ontology term." +# ) +# description: str | None = Field( +# default=None, +# description="Optional description or definition of the ontology term." +# ) + + +# class OntologyGroundedSurfaceTriple(BaseModel): +# subject: str = Field( +# description="Surface-form name or label of the subject entity. This is not a URI." +# ) + +# predicate_uri: AnyUrl = Field( +# description="URI of the ontology property, attribute, or relation used as the predicate." +# ) + +# object: str = Field( +# description="Surface-form value, entity name, label, literal, or textual object. This is not a URI." +# ) + +# subject_type_uri: AnyUrl | None = Field( +# default=None, +# description="Optional ontology class URI for the subject, if inferable from the input and ontology." +# ) + +# object_type_uri: AnyUrl | None = Field( +# default=None, +# description="Optional ontology class URI for the object, if inferable from the input and ontology." +# ) + +# evidence: str | None = Field( +# default=None, +# description="Short quote or compact excerpt from the input that supports this triple." +# ) + +# confidence: float = Field( +# ge=0.0, +# le=1.0, +# description="Model confidence that the triple is correct and uses the appropriate ontology predicate." +# ) + + +# class TripleExtractionIssue(BaseModel): +# message: str = Field( +# description="Description of an ambiguity, missing ontology term, or extraction problem." +# ) + +# severity: Literal["info", "warning", "error"] = Field( +# description="Severity of the issue." +# ) + +# related_text: str | None = Field( +# default=None, +# description="Optional source text related to the issue." +# ) + + +# class OntologySurfaceTripleExtractionResult(BaseModel): +# triples: list[OntologyGroundedSurfaceTriple] = Field( +# description="Extracted ontology-grounded surface triples." +# ) + +# unmapped_candidates: list[str] = Field( +# default_factory=list, +# description="Candidate relations or attributes found in the input that could not be mapped to the ontology." +# ) + +# issues: list[TripleExtractionIssue] = Field( +# default_factory=list, +# description="Warnings or errors encountered during extraction." +# ) \ No newline at end of file diff --git a/src/kgpipe_llm/test/test_any_extraction.py b/src/kgpipe_llm/test/test_any_extraction.py new file mode 100644 index 0000000..ab7ded6 --- /dev/null +++ b/src/kgpipe_llm/test/test_any_extraction.py @@ -0,0 +1,24 @@ +from kgpipe_llm.any_extraction import extract_ontology_surface_triples +from pathlib import Path +from kgpipe_llm.common.core import LLMClient +import os + +TEXT=""" +Titanic is a 1997 American epic historical romance film written and directed by James Cameron. Incorporating both historical and fictional aspects, it is based on accounts of the sinking of RMS Titanic in 1912. Leonardo DiCaprio and Kate Winslet star as members of different social classes who fall in love during the ship's ill-fated maiden voyage. The ensemble cast includes Billy Zane, Kathy Bates, Frances Fisher, Bernard Hill, Jonathan Hyde, Danny Nucci, David Warner and Bill Paxton. Cameron's inspiration came from his fascination with shipwrecks. He felt a love story interspersed with human loss would be essential to convey the emotional impact of the disaster. Production began on September 1, 1995, when Cameron shot footage of the Titanic wreck. The modern scenes were shot on board the Shirshov Institute of Oceanology research vessel Akademik Mstislav Keldysh, which Cameron had used as a base when filming the wreck. Scale models, computer-generated imagery (CGI), and a reconstruction of the Titanic built at Baja Studios were used to recreate the sinking. Titanic was initially in development at 20th Century Fox, but delays and a mounting budget resulted in Fox partnering with Paramount Pictures for financial help. It was the most expensive film ever made at the time, with a production budget of $200 million. Filming took place from July 1996 to March 1997. Titanic premiered at the Tokyo International Film Festival on November 1, 1997, and was released in the United States on December 19. It was distributed by Paramount Pictures in the United States and Canada and by 20th Century Fox in other territories. It was praised for its visual effects, performances (particularly those of DiCaprio, Winslet, and Gloria Stuart), production values, direction, score, cinematography, story, and emotional depth. Among other awards, the film received fourteen nominations at the 70th Academy Awards and won eleven, including Best Picture and Best Director. In doing so, it tied both All About Eve (1950) for the record for the most Academy Award nominations, and Ben-Hur (1959) for the most Academy Awards won by a film, making Titanic the most successful individual film in Academy Award history (these records would be matched by 2016's La La Land and 2003's The Lord of the Rings: The Return of the King respectively, although the nomination record was surpassed by 2025's Sinners in 2026). With an initial worldwide gross of over $1.84 billion, Titanic was the first film to reach the billion-dollar mark (1993's Jurassic Park would later become the earliest-released film to achieve this feat, via subsequent re-releases), and was the highest-grossing film of all time until Cameron's next film, Avatar (2009), surpassed it in 2010. Income from the initial theatrical release, retail video, and soundtrack sales and US broadcast rights exceeded $3.2 billion. Releases pushed the worldwide theatrical total to $2.264 billion, making Titanic the second film to gross more than $2 billion worldwide after Avatar; as of 2023, it is the fourth-highest-grossing film. In 2017, the Library of Congress selected it for preservation in the United States National Film Registry as "culturally, historically, or aesthetically significant +""" + +API_KEY = os.getenv("OPENAI_API_KEY") +if not API_KEY: + raise ValueError("OPENAI_API_KEY is not set") + +model_name="o4-mini" +ontology_path = Path("/home/marvin/phd/data/moviekg/datasets/film_10k/ontology.ttl") + +def test_extract_ontology_surface_triples(): + client = LLMClient( + model_name=model_name, + token=API_KEY, + api_type="openai", + ) + result = extract_ontology_surface_triples(TEXT, ontology_path, client) + print(result.model_dump_json(indent=2)) \ No newline at end of file From 72860dd0505f6fb26b06aaa9fd5672905e051438 Mon Sep 17 00:00:00 2001 From: Marvin Date: Tue, 12 May 2026 16:26:40 +0200 Subject: [PATCH 30/42] changes to kgi-bench mov eval --- .../moviekg/evaluation/test_eval_refactor.py | 16 +++++- .../src/moviekg/paper/helpers/helpers.py | 50 ++++++++++++++++--- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py index f7b739e..1dacc8f 100644 --- a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py +++ b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py @@ -4,6 +4,7 @@ from kgpipe_eval.metrics.statistics import CountMetric from kgpipe_eval.metrics.duplicates import DuplicateConfig, DuplicateMetric from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric +from kgpipe_eval.metrics.triple_alignment import TripleAlignmentConfig, ReferenceTripleAlignmentMetric from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig from kgpipe_eval.utils.kg_utils import KgLike, KgManager from kgpipe_eval.evaluator import Evaluator @@ -94,6 +95,17 @@ def build_config_dict(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> ) ) + tri_cfg = TripleAlignmentConfig( + reference_kg=bench_data.dataset.splits[f"split_{i}"].kg_reference, + entity_alignment_config=EntityAlignmentConfig( + method="label_embedding", + verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data + verified_entities_delimiter="\t", + entity_sim_threshold=0.95, + ), + value_sim_threshold=0.5, + ) + ent_cfg = EntityAlignmentConfig( method="label_embedding_and_intersecting_type", verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data @@ -105,6 +117,7 @@ def build_config_dict(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> return { "DuplicateMetric": dup_cfg, "EntityAlignmentMetric": ent_cfg, + "TripleAlignmentMetric": tri_cfg, } @@ -113,7 +126,8 @@ def evaluate_stage(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> Li metrics = [ CountMetric(), EntityAlignmentMetric(), - DuplicateMetric() + DuplicateMetric(), + ReferenceTripleAlignmentMetric(), ] config_dict = build_config_dict(i, pipe_data, bench_data) return Evaluator().run(tg, metrics, config_dict) diff --git a/experiments/moviekg/src/moviekg/paper/helpers/helpers.py b/experiments/moviekg/src/moviekg/paper/helpers/helpers.py index 161f55e..2dd3200 100644 --- a/experiments/moviekg/src/moviekg/paper/helpers/helpers.py +++ b/experiments/moviekg/src/moviekg/paper/helpers/helpers.py @@ -130,11 +130,34 @@ def plot_growth_v1(df, metrics): "text_json_rdf": "#d95f0e", "text_rdf_json": "#d95f0e", } +PALETTE_2 = { + # JSON solo + "JSON_base": "#9ecae1", + # "json_baseA": "#9ecae1", + # RDF solo + "RDF_base": "#a1d99b", "RDF_llm": "#2ca02c", + # TEXT solo + "TEXT_base": "#fdd0a2", + + # JSON mixed → violet + "json_rdf_text": "#756bb1", "json_text_rdf": "#756bb1", + # RDF mixed → teal + "rdf_json_text": "#1c9099", "rdf_text_json": "#1c9099", + # TEXT mixed → red-brown + "text_json_rdf": "#d95f0e", "text_rdf_json": "#d95f0e", +} + + HUE_ORDER = [ "json_a","json_b","json_rdf_text","json_text_rdf", "rdf_a","rdf_b","rdf_json_text","rdf_text_json", "text_a","text_b","text_json_rdf","text_rdf_json" ] +HUE_ORDER_2 = [ + "RDF_base","RDF_llm","rdf_json_text","rdf_text_json", + "JSON_base","json_rdf_text","json_text_rdf", + "TEXT_base","text_json_rdf","text_rdf_json" +] def plot_growth(df, metrics, kind="bar", references={}): """ @@ -636,11 +659,23 @@ def plot_class_occurence_new(df, reference_stage_class_count, classes): class_name = "Other" pipeline_stage_class_count[pipeline][stage][class_name] += count + pretty_pipeline_names = { + "json_a": "JSON_base", + "json_c": "JSON_llm", + "rdf_a": "RDF_base", + "rdf_c": "RDF_llm", + "text_a": "TEXT_base", + "text_c": "TEXT_llm", + "json_llm_mapping_v1": "JSON_llm", + "rdf_llm_schema_align_v1": "RDF_llm", + "text_llm_triple_extract_v1": "TEXT_llm", + } + # convert dict of dict to rows for pipeline, stage_class_count in pipeline_stage_class_count.items(): for stage, class_count in stage_class_count.items(): for class_name, count in class_count.items(): - rows.append({"pipeline": pipeline, "stage": stage, "class": class_name.split("/")[-1], "count": count}) + rows.append({"pipeline": pretty_pipeline_names.get(pipeline, pipeline), "stage": stage, "class": class_name.split("/")[-1], "count": count}) # df: pipeline, stage, class, count df = pd.DataFrame(rows) @@ -655,8 +690,8 @@ def plot_class_occurence_new(df, reference_stage_class_count, classes): df, col="class", col_wrap=3, - height=4, - aspect=1.5, + height=3, + aspect=1.2, sharey=False, col_order=classes_short #+["Other"], # preserve requested order ) @@ -665,8 +700,8 @@ def plot_class_occurence_new(df, reference_stage_class_count, classes): x="stage", y="count", hue="pipeline", - hue_order=HUE_ORDER, - palette=PALETTE, + hue_order=HUE_ORDER_2, + palette=PALETTE_2, order=stage_order, dodge=True, errorbar=None @@ -699,7 +734,7 @@ def plot_class_occurence_new(df, reference_stage_class_count, classes): handles, labels, loc="lower center", ncol=min(6, len(labels)), # split across columns - bbox_to_anchor=(0.5, -0.02) # push below the grid + bbox_to_anchor=(0.5, -0.15) # push below the grid ) # make space at bottom so legend isn’t cut off @@ -729,6 +764,9 @@ def plot_class_occ_4_bar_chart(df): # remove seed and reference pipeline df = df[df["pipeline"] != "seed"] + df = df[df["pipeline"] != "json_b"] + df = df[df["pipeline"] != "rdf_b"] + df = df[df["pipeline"] != "text_b"] df = df[df["pipeline"] != "reference"] # subplot_source_entity_integration(df) From a9cef343bbe91ed5b48c0ffeb8419dcec14e00c9 Mon Sep 17 00:00:00 2001 From: Marvin Date: Mon, 27 Apr 2026 22:19:13 +0200 Subject: [PATCH 31/42] stash --- .../src/param_opti/tasks/base_linker.py | 22 +- .../src/param_opti/tasks/base_linker_lib.py | 214 ++++++++ .../src/param_opti/tasks/base_matcher.py | 146 ++++-- .../src/param_opti/tasks/base_matcher_lib.py | 234 +++++++++ .../src/param_opti/tasks/corenlp.py | 36 ++ .../src/param_opti/tasks/corenlp_lip.py | 148 ++++++ .../param-opti/src/param_opti/tasks/fusion.py | 38 +- .../src/param_opti/tasks/fusion_lib.py | 205 ++++++++ .../param-opti/src/param_opti/tasks/genie.py | 14 + .../src/param_opti/tasks/genie_lib.py | 0 .../param-opti/src/param_opti/tasks/jedai.py | 1 + .../src/param_opti/tasks/matching_helpers.py | 30 ++ .../param-opti/src/param_opti/tasks/paris.py | 45 +- .../src/param_opti/tasks/paris_lib.py | 152 ++++++ .../src/param_opti/tasks/select_lib.py | 119 +++++ .../src/param_opti/tasks/spotlight.py | 23 + .../src/param_opti/tasks/spotlight_lib.py | 187 +++++++ .../src/param_opti/tasks/text_helpers.py | 348 +++++++++++++ .../rdf_sampled_pipeline_configs.json | 175 +++++++ experiments/param-opti/src/qap/sge_metrics.py | 17 + .../param-opti/src/qap/test_exec_pipelines.py | 170 +++++++ .../src/qap/test_pipeline_config.py | 463 +++++++++++++++--- .../param-opti/src/qap/test_ref_based.py | 199 +++++++- .../param-opti/src/qap/test_sge_based.py | 36 ++ src/kgpipe/common/model/configuration.py | 8 +- src/kgpipe_eval/metrics/triple_alignment.py | 60 ++- src/kgpipe_eval/test/examples.py | 84 +++- src/kgpipe_eval/test/test_alignment_eval.py | 34 +- src/kgpipe_eval/test/utils.py | 10 + src/kgpipe_eval/utils/alignment_utils.py | 245 ++++++++- src/kgpipe_eval/utils/kg_utils.py | 20 + src/kgpipe_llm/common/core.py | 16 +- 32 files changed, 3341 insertions(+), 158 deletions(-) create mode 100644 experiments/param-opti/src/param_opti/tasks/base_linker_lib.py create mode 100644 experiments/param-opti/src/param_opti/tasks/base_matcher_lib.py create mode 100644 experiments/param-opti/src/param_opti/tasks/corenlp.py create mode 100644 experiments/param-opti/src/param_opti/tasks/corenlp_lip.py create mode 100644 experiments/param-opti/src/param_opti/tasks/fusion_lib.py create mode 100644 experiments/param-opti/src/param_opti/tasks/genie.py create mode 100644 experiments/param-opti/src/param_opti/tasks/genie_lib.py create mode 100644 experiments/param-opti/src/param_opti/tasks/matching_helpers.py create mode 100644 experiments/param-opti/src/param_opti/tasks/paris_lib.py create mode 100644 experiments/param-opti/src/param_opti/tasks/select_lib.py create mode 100644 experiments/param-opti/src/param_opti/tasks/spotlight_lib.py create mode 100644 experiments/param-opti/src/param_opti/tasks/text_helpers.py create mode 100644 experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json create mode 100644 experiments/param-opti/src/qap/sge_metrics.py create mode 100644 experiments/param-opti/src/qap/test_exec_pipelines.py create mode 100644 experiments/param-opti/src/qap/test_sge_based.py diff --git a/experiments/param-opti/src/param_opti/tasks/base_linker.py b/experiments/param-opti/src/param_opti/tasks/base_linker.py index 5af3df4..1d09432 100644 --- a/experiments/param-opti/src/param_opti/tasks/base_linker.py +++ b/experiments/param-opti/src/param_opti/tasks/base_linker.py @@ -5,17 +5,14 @@ def relation_linker_label_alias_embedding_transformer_function(inputs: TaskInput """ Link relations using a base transformer model. """ - pass - # relation_text = inputs["relation_text"] - # relation_linker = RelationLinkerBaseTransformer(relation_text) - # relation_linker.link() - # outputs["relation_link"] = relation_linker.relation_link + from param_opti.tasks.base_linker_lib import label_alias_embedding_rl + label_alias_embedding_rl(inputs, outputs, model_name=config.get_parameter_value("model_name"), threshold=config.get_parameter_value("similarity_threshold")) relation_linker_label_alias_embedding_transformer_task = KgTask( name="relation_linker_label_alias_embedding_transformer", function=relation_linker_label_alias_embedding_transformer_function, - input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, - output_spec={"output": DataFormat.RDF}, + input_spec={"source": DataFormat.TE_JSON, "target": DataFormat.RDF_NTRIPLES}, + output_spec={"output": DataFormat.TE_JSON}, config_spec=ConfigurationDefinition( name="relation_linker_label_alias_embedding_transformer", parameters=[ @@ -29,17 +26,14 @@ def entity_linker_label_alias_embedding_transformer_function(inputs: TaskInput, """ Link entities using a base transformer model. """ - pass - # entity_text = inputs["entity_text"] - # entity_linker = EntityLinkerBaseTransformer(entity_text) - # entity_linker.link() - # outputs["entity_link"] = entity_linker.entity_link + from param_opti.tasks.base_linker_lib import label_alias_embedding_el + label_alias_embedding_el(inputs, outputs, model_name=config.get_parameter_value("model_name"), threshold=config.get_parameter_value("similarity_threshold")) entity_linker_label_alias_embedding_transformer_task = KgTask( name="entity_linker_label_alias_embedding_transformer", function=entity_linker_label_alias_embedding_transformer_function, - input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, - output_spec={"output": DataFormat.RDF}, + input_spec={"source": DataFormat.TE_JSON, "target": DataFormat.RDF_NTRIPLES}, + output_spec={"output": DataFormat.TE_JSON}, config_spec=ConfigurationDefinition( name="entity_linker_label_alias_embedding_transformer", parameters=[ diff --git a/experiments/param-opti/src/param_opti/tasks/base_linker_lib.py b/experiments/param-opti/src/param_opti/tasks/base_linker_lib.py new file mode 100644 index 0000000..680a575 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/base_linker_lib.py @@ -0,0 +1,214 @@ +import json +import os +from abc import ABC, abstractmethod +from typing import Dict, List + +import numpy as np +import torch +from kgcore.api.ontology import OntologyUtil, OwlProperty +from kgpipe.common import Data, DataFormat, Registry +from kgpipe_tasks.transform_interop.exchange.text_extraction import TE_Document, TE_Pair +from rdflib import Graph, RDFS +from sentence_transformers import SentenceTransformer, util +from tqdm import tqdm + +_models: Dict[str, SentenceTransformer] = {} + +class Embedder(ABC): + def __init__(self, embedder_name: str): + self.embedder_name = embedder_name + + @abstractmethod + def encode_as_dict(self, texts: List[str]) -> Dict[str, np.ndarray]: + pass + + @abstractmethod + def encode(self, texts: List[str]) -> np.ndarray: + pass + + +def get_model(model_name: str) -> SentenceTransformer: + if model_name not in _models: + model = SentenceTransformer(model_name) + if torch.cuda.is_available(): + model.to(torch.cuda.current_device()) + _models[model_name] = model + return _models[model_name] + +class SentenceTransformerEmbedder(Embedder): + def __init__(self, model_name: str): + super().__init__("sentence-transformer") + self.model_name = model_name + + def encode_as_dict(self, text_list: List[str]) -> Dict[str, np.ndarray]: + embeddings = self.encode(text_list) + return {text: embedding for text, embedding in zip(text_list, embeddings)} + + def encode(self, text_list: List[str]) -> np.ndarray: + embeddings = get_model(self.model_name).encode(text_list, show_progress_bar=False) + return embeddings + +class EntityMatch: + def __init__(self, entity: str, label: str, score: float): + self.entity = entity + self.label = label + self.score = score + + +def _validate_embedding_dimensions(query_embeddings: np.ndarray, target_embeddings: np.ndarray) -> None: + if query_embeddings.shape[1] != target_embeddings.shape[1]: + raise ValueError( + "Embedding dimension mismatch: " + f"{query_embeddings.shape[1]} vs {target_embeddings.shape[1]}" + ) + + +class AliasAndLabelBasedEntityLinker: + """ + Link extracted entity mentions to graph resources using label embeddings. + """ + + def __init__(self, graph: Graph, model_name: str = "all-MiniLM-L6-v2", threshold: float = 0.0): + self.graph = graph + self.embedder = SentenceTransformerEmbedder(model_name=model_name) + self.threshold = float(threshold) + self.entity_uri_label_tuples = [ + (entity_uri, str(label)) + for entity_uri, _, label in self.graph.triples((None, RDFS.label, None)) + ] + entity_texts = [label for _, label in self.entity_uri_label_tuples] + self.entity_embeddings = self.embedder.encode(entity_texts) + + def link_entities(self, extracted_entities: List[str]) -> List[EntityMatch]: + if not extracted_entities: + return [] + + best_matches = [] + key_embeddings = self.embedder.encode(extracted_entities) + _validate_embedding_dimensions(key_embeddings, self.entity_embeddings) + similarities = util.cos_sim(key_embeddings, self.entity_embeddings) + + for i, entity in enumerate(extracted_entities): + best_idx = int(similarities[i].argmax()) + best_score = float(similarities[i][best_idx]) + if best_score < self.threshold: + continue + entity_uri, _ = self.entity_uri_label_tuples[best_idx] + best_matches.append(EntityMatch(entity, entity_uri, best_score)) + + return best_matches + + + +def label_alias_embedding_el(inputs: Dict[str, Data], outputs: Dict[str, Data], model_name: str = "all-MiniLM-L6-v2", threshold: float = 0.5): + graph = Graph() + graph.parse(inputs["target"].path, format="nt") + linker = AliasAndLabelBasedEntityLinker(graph, model_name=model_name, threshold=threshold) + + if os.path.isdir(inputs["source"].path): + os.makedirs(outputs["output"].path, exist_ok=True) + for file in tqdm(os.listdir(inputs["source"].path), desc="Linking entities"): + te_doc_in = TE_Document(**json.load(open(os.path.join(inputs["source"].path, file)))) + entity_texts = list({triple.subject.surface_form for triple in te_doc_in.triples if triple.subject.surface_form}) + entity_texts += list({triple.object.surface_form for triple in te_doc_in.triples if triple.object.surface_form}) + entity_matches = linker.link_entities(entity_texts) + te_links = [TE_Pair(span=match.entity, mapping=match.label, link_type="entity", score=match.score) for match in entity_matches] + te_doc_out = te_doc_in.model_copy(deep=True) + te_doc_out.links += te_links + with open(os.path.join(outputs["output"].path, file), "w") as f: + f.write(te_doc_out.model_dump_json()) + else: + te_doc_in = TE_Document(**json.load(open(inputs["source"].path))) + entity_matches = linker.link_entities(list({triple.subject.surface_form for triple in te_doc_in.triples if triple.subject.surface_form})) + te_links = [TE_Pair(span=match.entity, mapping=match.label, link_type="entity", score=match.score) for match in entity_matches] + te_doc_out = te_doc_in.model_copy(deep=True) + te_doc_out.links += te_links + with open(outputs["output"].path, "w") as f: + f.write(te_doc_out.model_dump_json()) + + + +class RelationMatch: + def __init__(self, relation: str, predicate: OwlProperty, score: float): + self.relation = relation + self.predicate = predicate + self.score = score + + def __str__(self): + return f"RelationMatch(relation={self.relation}, predicate={self.predicate.uri}, score={self.score})" + + +def normalize(text): + return text.replace('_', ' ').replace('-', ' ').strip().lower() + +def build_property_text(prop: OwlProperty): + text_parts = [ + f"label: {normalize(prop.label)}", + f"altLabels: {', '.join(normalize(lbl) for lbl in prop.alias)}" + # f"domain: {normalize(prop.get('domain', ''))}", + # f"comment: {normalize(prop.get('comment', ''))}" + ] + return "; ".join(text_parts) + +class AliasAndTransformerBasedRelationLinker: + """ + Link extracted relation phrases to ontology predicates using label and alias embeddings. + """ + + def __init__(self, ontology_file, model_name: str = "all-MiniLM-L6-v2", threshold: float = 0.0): + self.ontology = OntologyUtil.load_ontology_from_file(ontology_file) + self.embedder = SentenceTransformerEmbedder(model_name=model_name) + self.threshold = float(threshold) + property_texts = [build_property_text(p) for p in self.ontology.properties] + self.property_embeddings = self.embedder.encode(property_texts) + + def link_relations(self, extracted_relations: List[str]) -> List[RelationMatch]: + if not extracted_relations: + return [] + + best_matches = [] + key_texts = [normalize(relation) for relation in extracted_relations] + key_embeddings = self.embedder.encode(key_texts) + _validate_embedding_dimensions(key_embeddings, self.property_embeddings) + similarities = util.cos_sim(key_embeddings, self.property_embeddings) + + for i, relation in enumerate(extracted_relations): + best_idx = int(similarities[i].argmax()) + best_score = float(similarities[i][best_idx]) + if best_score < self.threshold: + continue + match = self.ontology.properties[best_idx] + best_matches.append(RelationMatch(relation, match, best_score)) + + return best_matches + + +def label_alias_embedding_rl(inputs: Dict[str, Data], outputs: Dict[str, Data], model_name: str = "all-MiniLM-L6-v2", threshold: float = 0.5): + + ontology_path = os.environ.get("ONTOLOGY_PATH", "false") + if ontology_path == "false": + raise ValueError("ONTOLOGY_PATH is not set") + else: + ontology_path = ontology_path + + linker = AliasAndTransformerBasedRelationLinker(ontology_path, model_name=model_name, threshold=threshold) + + if os.path.isdir(inputs["source"].path): + os.makedirs(outputs["output"].path, exist_ok=True) + for file in tqdm(os.listdir(inputs["source"].path), desc="Linking relations"): + te_doc_in = TE_Document(**json.load(open(os.path.join(inputs["source"].path, file)))) + relation_texts = list({triple.predicate.surface_form for triple in te_doc_in.triples if triple.predicate.surface_form}) + relation_matches = linker.link_relations(relation_texts) + te_links = [TE_Pair(span=match.relation, mapping=match.predicate.uri, link_type="predicate", score=match.score) for match in relation_matches] + te_doc_out = te_doc_in.model_copy(deep=True) + te_doc_out.links += te_links + with open(os.path.join(outputs["output"].path, file), "w") as f: + f.write(te_doc_out.model_dump_json()) + else: + te_doc_in = TE_Document(**json.load(open(inputs["source"].path))) # TODO: check if this is correct + relation_matches = linker.link_relations(list({triple.predicate.surface_form for triple in te_doc_in.triples if triple.predicate.surface_form})) + te_links = [TE_Pair(span=match.relation, mapping=match.predicate.uri, link_type="predicate", score=match.score) for match in relation_matches] + te_doc_out = te_doc_in.model_copy(deep=True) + te_doc_out.links += te_links + with open(outputs["output"].path, "w") as f: + f.write(te_doc_out.model_dump_json()) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/base_matcher.py b/experiments/param-opti/src/param_opti/tasks/base_matcher.py index d70e7ac..a43bc45 100644 --- a/experiments/param-opti/src/param_opti/tasks/base_matcher.py +++ b/experiments/param-opti/src/param_opti/tasks/base_matcher.py @@ -1,46 +1,110 @@ -from kgpipe.common import TaskInput, TaskOutput, Data, DataFormat, Registry, BasicTaskCategoryCatalog -from kgpipe.common.model.configuration import ConfigurationDefinition, Parameter, ParameterType - -@Registry.task( - input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, - output_spec={"output": DataFormat.AGREEMENTMAKER_RDF}, - description="Perform entity matching using AgreementMaker", - category=[BasicTaskCategoryCatalog.entity_matching], - config_spec=ConfigurationDefinition( - parameters=[ - Parameter(name="model_name", type=ParameterType.STRING, default="sentence-transformers/all-MiniLM-L6-v2"), - Parameter(name="similarity_threshold", type=ParameterType.NUMBER, default=0.5), - ] +from kgpipe.common import TaskInput, TaskOutput, DataFormat +from kgpipe.common.model.configuration import ConfigurationDefinition, Parameter, ParameterType, ConfigurationProfile +from kgpipe.common.model.task import KgTask + +# Same as paris_graph_alignment_task / paris_entity_alignment_task: +# input_spec + output_spec as in experiments/param-opti/src/param_opti/tasks/paris.py (e.g. lines 58–59). +_ALIGNMENT_TWO_GRAPH_INPUT_SPEC = {"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES} +_ALIGNMENT_ER_JSON_OUTPUT_SPEC = {"output": DataFormat.ER_JSON} + + +def _embedding_config_params(): + return [ + Parameter( + name="model_name", + native_keys=["--model-name"], + datatype=ParameterType.string, + default_value="sentence-transformers/all-MiniLM-L6-v2", + required=True, + allowed_values=[ + "sentence-transformers/all-MiniLM-L6-v2", + "sentence-transformers/all-mpnet-base-v2", + ], + ), + Parameter( + name="similarity_threshold", + native_keys=["--similarity-threshold"], + datatype=ParameterType.number, + default_value=0.5, + required=True, + allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], + ), + ] + + +def graph_alignment_label_alias_embedding_transformer_function( + inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile +): + """Match entities and relations between two RDF graphs (full graph alignment).""" + from param_opti.tasks.base_matcher_lib import label_embedding_graph_alignment_match + + label_embedding_graph_alignment_match( + inputs, + outputs, + model_name=config.get_parameter_value("model_name"), + threshold=float(config.get_parameter_value("similarity_threshold")), ) + + +graph_alignment_label_alias_embedding_transformer_task = KgTask( + name="graph_alignment_label_alias_embedding_transformer", + function=graph_alignment_label_alias_embedding_transformer_function, + input_spec=dict(_ALIGNMENT_TWO_GRAPH_INPUT_SPEC), + output_spec=dict(_ALIGNMENT_ER_JSON_OUTPUT_SPEC), + config_spec=ConfigurationDefinition( + name="graph_alignment_label_alias_embedding_transformer", + parameters=_embedding_config_params(), + ), ) -def relation_matcher_label_alias_embedding_transformer(inputs: TaskInput, outputs: TaskOutput): - """ - Match relations using a base transformer model. - """ - pass - # relation_text = inputs["relation_text"] - # relation_matcher = RelationMatcherBaseTransformer(relation_text) - # relation_matcher.match() - # outputs["relation_matcher"] = relation_matcher.relation_matcher - -@Registry.task( - input_spec={"source": DataFormat.RDF, "target": DataFormat.RDF}, - output_spec={"output": DataFormat.AGREEMENTMAKER_RDF}, - description="Perform entity matching using AgreementMaker", - category=[BasicTaskCategoryCatalog.entity_matching], + + +def entity_matcher_label_alias_embedding_transformer_function( + inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile +): + """Entity alignment only (subject/object URIs with rdfs:label).""" + from param_opti.tasks.base_matcher_lib import label_embedding_entity_alignment_match + + label_embedding_entity_alignment_match( + inputs, + outputs, + model_name=config.get_parameter_value("model_name"), + threshold=float(config.get_parameter_value("similarity_threshold")), + ) + + +entity_matcher_label_alias_embedding_transformer_task = KgTask( + name="entity_matcher_label_alias_embedding_transformer", + function=entity_matcher_label_alias_embedding_transformer_function, + input_spec=dict(_ALIGNMENT_TWO_GRAPH_INPUT_SPEC), + output_spec=dict(_ALIGNMENT_ER_JSON_OUTPUT_SPEC), config_spec=ConfigurationDefinition( - parameters=[ - Parameter(name="model_name", type=ParameterType.STRING, default="sentence-transformers/all-MiniLM-L6-v2"), - Parameter(name="similarity_threshold", type=ParameterType.NUMBER, default=0.5), - ] + name="entity_matcher_label_alias_embedding_transformer", + parameters=_embedding_config_params(), + ), +) + + +def relation_matcher_label_alias_embedding_transformer_function( + inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile +): + """Relation / predicate alignment only.""" + from param_opti.tasks.base_matcher_lib import label_embedding_relation_alignment_match + + label_embedding_relation_alignment_match( + inputs, + outputs, + model_name=config.get_parameter_value("model_name"), + threshold=float(config.get_parameter_value("similarity_threshold")), ) + + +relation_matcher_label_alias_embedding_transformer_task = KgTask( + name="relation_matcher_label_alias_embedding_transformer", + function=relation_matcher_label_alias_embedding_transformer_function, + input_spec=dict(_ALIGNMENT_TWO_GRAPH_INPUT_SPEC), + output_spec=dict(_ALIGNMENT_ER_JSON_OUTPUT_SPEC), + config_spec=ConfigurationDefinition( + name="relation_matcher_label_alias_embedding_transformer", + parameters=_embedding_config_params(), + ), ) -def entity_matcher_label_alias_embedding_transformer(inputs: TaskInput, outputs: TaskOutput): - """ - Match entities using a base transformer model. - """ - pass - # entity_text = inputs["entity_text"] - # entity_matcher = EntityMatcherBaseTransformer(entity_text) - # entity_matcher.match() - # outputs["entity_matcher"] = entity_matcher.entity_matcher \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/base_matcher_lib.py b/experiments/param-opti/src/param_opti/tasks/base_matcher_lib.py new file mode 100644 index 0000000..6ca3f41 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/base_matcher_lib.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence + +from rdflib import Graph, Literal, RDFS, URIRef +from sentence_transformers import util + +from kgpipe.common import Data +from kgpipe_tasks.transform_interop.exchange.entity_matching import ER_Document, ER_Match + +# Reuse the shared embedder/model cache from the linker implementation. +from param_opti.tasks.base_linker_lib import SentenceTransformerEmbedder, _validate_embedding_dimensions + + +def _normalize_label(text: str) -> str: + return " ".join(text.replace("_", " ").replace("-", " ").strip().lower().split()) + + +def _safe_first_literal(values: Iterable[object]) -> Optional[str]: + for v in values: + if isinstance(v, Literal): + s = str(v).strip() + if s: + return s + return None + + +def _fallback_label_from_uri(uri: URIRef) -> str: + s = str(uri) + if "#" in s: + return s.rsplit("#", 1)[-1] + return s.rsplit("/", 1)[-1] + + +@dataclass(frozen=True) +class _LabeledUri: + uri: URIRef + label: str + + +def _extract_labeled_entities(graph: Graph) -> List[_LabeledUri]: + """ + Extract subject/object URIRefs that have an rdfs:label. + """ + uris: set[URIRef] = set() + for s, _, o in graph: + if isinstance(s, URIRef): + uris.add(s) + if isinstance(o, URIRef): + uris.add(o) + + labeled: List[_LabeledUri] = [] + for u in uris: + label = _safe_first_literal(graph.objects(u, RDFS.label)) + if label: + labeled.append(_LabeledUri(u, label)) + return labeled + + +def _extract_labeled_predicates(graph: Graph) -> List[_LabeledUri]: + """ + Extract predicate URIRefs and use rdfs:label if present, otherwise fall back to local-name. + """ + preds: set[URIRef] = {p for _, p, _ in graph if isinstance(p, URIRef)} + labeled: List[_LabeledUri] = [] + for p in preds: + label = _safe_first_literal(graph.objects(p, RDFS.label)) or _fallback_label_from_uri(p) + labeled.append(_LabeledUri(p, label)) + return labeled + + +def _best_matches( + source: Sequence[_LabeledUri], + target: Sequence[_LabeledUri], + *, + model_name: str, + threshold: float, + id_type: str, +) -> List[ER_Match]: + if not source or not target: + return [] + + embedder = SentenceTransformerEmbedder(model_name=model_name) + src_texts = [_normalize_label(x.label) for x in source] + tgt_texts = [_normalize_label(x.label) for x in target] + + src_emb = embedder.encode(src_texts) + tgt_emb = embedder.encode(tgt_texts) + _validate_embedding_dimensions(src_emb, tgt_emb) + + sims = util.cos_sim(src_emb, tgt_emb) + matches: List[ER_Match] = [] + + for i, src in enumerate(source): + best_idx = int(sims[i].argmax()) + best_score = float(sims[i][best_idx]) + if best_score < float(threshold): + continue + tgt = target[best_idx] + matches.append( + ER_Match( + id_1=str(src.uri), + id_2=str(tgt.uri), + score=best_score, + id_type=id_type, + ) + ) + return matches + + +def _write_er_document(output_path: Path, matches: List[ER_Match]) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + doc = ER_Document(matches=matches) + output_path.write_text(doc.model_dump_json(), encoding="utf-8") + + +def _label_embedding_match_two_graphs( + inputs: Dict[str, Data], + outputs: Dict[str, Data], + *, + model_name: str, + threshold: float, + include_entities: bool, + include_relations: bool, +) -> None: + source_graph = Graph() + source_graph.parse(inputs["source"].path, format="nt") + + target_graph = Graph() + target_graph.parse(inputs["target"].path, format="nt") + + matches: List[ER_Match] = [] + + if include_entities: + source_entities = _extract_labeled_entities(source_graph) + target_entities = _extract_labeled_entities(target_graph) + matches.extend( + _best_matches( + source_entities, + target_entities, + model_name=model_name, + threshold=threshold, + id_type="entity", + ) + ) + + if include_relations: + source_preds = _extract_labeled_predicates(source_graph) + target_preds = _extract_labeled_predicates(target_graph) + matches.extend( + _best_matches( + source_preds, + target_preds, + model_name=model_name, + threshold=threshold, + id_type="relation", + ) + ) + + _write_er_document(outputs["output"].path, matches) + + +def label_embedding_graph_alignment_match( + inputs: Dict[str, Data], + outputs: Dict[str, Data], + *, + model_name: str = "sentence-transformers/all-MiniLM-L6-v2", + threshold: float = 0.5, +) -> None: + """ + Align two RDF graphs: match subject/object entities by rdfs:label and predicates by label. + + Writes `ER_Document` JSON with both entity and relation matches (same shape as `paris_lib`). + """ + _label_embedding_match_two_graphs( + inputs, + outputs, + model_name=model_name, + threshold=threshold, + include_entities=True, + include_relations=True, + ) + + +def label_embedding_entity_alignment_match( + inputs: Dict[str, Data], + outputs: Dict[str, Data], + *, + model_name: str = "sentence-transformers/all-MiniLM-L6-v2", + threshold: float = 0.5, +) -> None: + """Entity alignment only: matches with id_type \"entity\".""" + _label_embedding_match_two_graphs( + inputs, + outputs, + model_name=model_name, + threshold=threshold, + include_entities=True, + include_relations=False, + ) + + +def label_embedding_relation_alignment_match( + inputs: Dict[str, Data], + outputs: Dict[str, Data], + *, + model_name: str = "sentence-transformers/all-MiniLM-L6-v2", + threshold: float = 0.5, +) -> None: + """Relation / predicate alignment only: matches with id_type \"relation\".""" + _label_embedding_match_two_graphs( + inputs, + outputs, + model_name=model_name, + threshold=threshold, + include_entities=False, + include_relations=True, + ) + + +def label_embedding_graph_match( + inputs: Dict[str, Data], + outputs: Dict[str, Data], + *, + model_name: str = "sentence-transformers/all-MiniLM-L6-v2", + threshold: float = 0.5, +) -> None: + """Backward-compatible alias for full graph alignment (entities + relations).""" + label_embedding_graph_alignment_match( + inputs, outputs, model_name=model_name, threshold=threshold + ) + diff --git a/experiments/param-opti/src/param_opti/tasks/corenlp.py b/experiments/param-opti/src/param_opti/tasks/corenlp.py new file mode 100644 index 0000000..e45299b --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/corenlp.py @@ -0,0 +1,36 @@ +from typing import Dict + +from pathlib import Path +from kgpipe.common import Data, DataFormat, Registry, KgTask +from kgpipe.common.model.configuration import ConfigurationDefinition + + +def corenlp_text_extraction_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): + from param_opti.tasks.corenlp_lip import corenlp_openie_extraction, corenlp_exchange + + # Ensure parent directory exists for the TE JSON output path + outputs["output"].path.parent.mkdir(parents=True, exist_ok=True) + + input_path: Path = inputs["input"].path + final_te_output: Data = outputs["output"] + + # 1) Produce intermediate OpenIE JSON (file or directory) + if input_path.is_dir(): + openie_out_path = final_te_output.path.parent / f"{final_te_output.path.stem}_corenlp_openie_out" + else: + openie_out_path = final_te_output.path.parent / f"{final_te_output.path.stem}_corenlp_openie.json" + + openie_output = {"output": Data(openie_out_path, DataFormat.OPENIE_JSON)} + corenlp_openie_extraction({"input": inputs["input"]}, openie_output) + + # 2) Convert OpenIE JSON → TE JSON (final output) + corenlp_exchange({"input": openie_output["output"]}, {"output": final_te_output}) + + +corenlp_text_extraction_task = KgTask( + name="corenlp_text_extraction", + input_spec={"input": DataFormat.TEXT}, + output_spec={"output": DataFormat.TE_JSON}, + function=corenlp_text_extraction_function, + description="Extract text using CoreNLP" +) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/corenlp_lip.py b/experiments/param-opti/src/param_opti/tasks/corenlp_lip.py new file mode 100644 index 0000000..4ef7559 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/corenlp_lip.py @@ -0,0 +1,148 @@ +from kgpipe.common import TaskInput, TaskOutput, Data, DataFormat, Registry, BasicTaskCategoryCatalog +from kgpipe.common.model.configuration import ConfigurationDefinition, Parameter, ParameterType, ConfigurationProfile +from kgpipe.common.model.task import KgTask + +def openie_pipeline_task_function(inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile): + """ + Run the openie pipeline + """ + pass + +@Registry.task( + input_spec={"input": DataFormat.TEXT}, + output_spec={"output": DataFormat.OPENIE_JSON}, + description="Extract OpenIE triples using Stanford CoreNLP", + category=["TextProcessing", "TextExtraction"] +) +def openie_pipeline_task(inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile): + """ + Run the openie pipeline + """ + pass + +""" +Stanford CoreNLP Information Extraction + +This module provides information extraction using Stanford CoreNLP. +""" + +import json +import os +from pathlib import Path +from typing import Dict, Any, List + +from kgpipe.common import KgTask, Data, DataFormat, Registry +from kgpipe.common.io import get_docker_volume_bindings, remap_data_path_for_container +from kgpipe.execution import docker_client + + +CORENLP_ENTRYPOINT = ["java", "-cp", "*", "edu.stanford.nlp.pipeline.StanfordCoreNLP"] + + +def corenlp_openie_extraction(inputs: Dict[str, Data], outputs: Dict[str, Data]): + """Extract OpenIE triples using Stanford CoreNLP.""" + # input_data = inputs["input"] + # output_data = outputs["output"] + + # Setup Docker + all_data = list(inputs.values()) + list(outputs.values()) + volumes, host_to_container = get_docker_volume_bindings(all_data) + + print(inputs["input"]) + print(outputs["output"]) + # Remap paths for container + input_path = remap_data_path_for_container(inputs["input"], host_to_container) + output_path = remap_data_path_for_container(outputs["output"], host_to_container) + + # Create command + command = ["bash", "openie.sh", str(input_path.path), str(output_path.path)] + # CORENLP_ENTRYPOINT + [ + # "-annotators", "tokenize,pos,lemma,ner,parse,coref,openie", + # "-file", str(input_path.path), + # "-outputFormat", "json", + # "-outputDirectory", str(output_path.path) + # ] + + # Run container + client = docker_client( + image="kgt/corenlp:latest", + command=command, + volumes=volumes + ) + client() + + +def corenlp_exchange(inputs: Dict[str, Data], outputs: Dict[str, Data]): + """Convert OpenIE JSON to IE JSON format.""" + input_path = inputs["input"].path + output_path = outputs["output"].path + + # create output folder + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + def __openiejson2tejson(openiedata) -> Dict[str, Any]: + """Convert OpenIE JSON to TE Document format.""" + doc = {"triples": [], "chains": []} + + # Convert to triples + triplets = [] + for sentence in openiedata.get('sentences', []): + for triple_span in sentence.get('openie', []): + triplet = { + "subject": {"surface_form": triple_span.get('subject', '')}, + "predicate": {"surface_form": triple_span.get('relation', '')}, + "object": {"surface_form": triple_span.get('object', '')} + } + triplets.append(triplet) + + # Get chains (simplified) + chains = get_coreference_chains(openiedata) + + doc["triples"] = triplets + doc["chains"] = chains + return doc + + if os.path.isdir(input_path): + os.makedirs(output_path, exist_ok=True) + for file in os.listdir(input_path): + # Read input json + with open(os.path.join(input_path, file), 'r') as f: + data = json.load(f) + te_doc = __openiejson2tejson(data) + outfile = os.path.join(output_path, file) + + with open(outfile, 'w') as of: + json.dump(te_doc, of) + # print(f"Converted {input_path} to {outfile}") + + else: + # Read input json + with open(input_path, 'r') as f: + data = json.load(f) + te_doc = __openiejson2tejson(data) + with open(output_path, 'w') as of: + json.dump(te_doc, of) + # print(f"Converted {input_path} to {output_path}") + + +def get_coreference_chains(response: dict) -> List[Dict[str, Any]]: + """Extract coreference chains from CoreNLP response.""" + result = [] + for _, coref in response.get('corefs', {}).items(): + if len(coref) > 1: + chain = {"main": coref[0].get('text', '')} + alias = [] + for chunk in coref[1:]: + sentence = response.get('sentences', [])[chunk.get('sentNum', 1) - 1] + start = sentence.get('tokens', [])[chunk.get('startIndex', 1) - 1].get('characterOffsetBegin', 0) + end = sentence.get('tokens', [])[chunk.get('endIndex', 2) - 2].get('characterOffsetEnd', 0) + alias.append({ + "surface_form": chunk.get('text', ''), + "text": chunk.get('text', ''), + "start": start, + "end": end + }) + chain["aliases"] = alias + result.append(chain) + return result + diff --git a/experiments/param-opti/src/param_opti/tasks/fusion.py b/experiments/param-opti/src/param_opti/tasks/fusion.py index ee73e29..0c6eec3 100644 --- a/experiments/param-opti/src/param_opti/tasks/fusion.py +++ b/experiments/param-opti/src/param_opti/tasks/fusion.py @@ -1,15 +1,39 @@ +import os + from kgpipe.common.model.configuration import ConfigurationProfile +from kgpipe.common.model.configuration import ConfigurationDefinition, Parameter, ParameterType from kgpipe.common.models import TaskInput, TaskOutput, KgTask, DataFormat -def fusion_first_value_function(inputs: TaskInput, outputs: TaskOutput): - # touch output file - outputs["output"].path.touch() +def fusion_first_value_function( + inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile | None = None +): + from param_opti.tasks.fusion_lib import fusion_first_value + + if config is not None: + ontology_path = config.get_parameter_value("ontology_path") + else: + ontology_path = os.environ.get("ONTOLOGY_PATH", "") + # TODO remove thresholds as they are applied by the matchers + fusion_first_value( + inputs, + outputs, + entity_matching_threshold=0.0, + relation_matching_threshold=0.0, + ontology_path=ontology_path, + ) fusion_first_value_task = KgTask( - name="fusion_first_value", + name="fusion_first_value_task", function=fusion_first_value_function, - input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES, "matches": DataFormat.ER_JSON}, - output_spec={"output": DataFormat.RDF_NTRIPLES}, + input_spec={"source": DataFormat.RDF_NTRIPLES, "kg": DataFormat.RDF_NTRIPLES, "matches1": DataFormat.ER_JSON}, + output_spec={"output": DataFormat.RDF_NTRIPLES} + # config_spec=ConfigurationDefinition( + # name="fusion_first_value", + # parameters=[ + # # ontology path + # Parameter(name="ontology_path", native_keys=["--ontology-path"], datatype=ParameterType.string, default_value="", required=True), + # ] + # ) ) def fusion_union_function(inputs: TaskInput, outputs: TaskOutput): @@ -17,7 +41,7 @@ def fusion_union_function(inputs: TaskInput, outputs: TaskOutput): outputs["output"].path.touch() fusion_union_task = KgTask( - name="fusion_union", + name="fusion_union_task", function=fusion_union_function, input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES, "matches": DataFormat.ER_JSON}, output_spec={"output": DataFormat.RDF_NTRIPLES}, diff --git a/experiments/param-opti/src/param_opti/tasks/fusion_lib.py b/experiments/param-opti/src/param_opti/tasks/fusion_lib.py new file mode 100644 index 0000000..0581ad7 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/fusion_lib.py @@ -0,0 +1,205 @@ + +from kgpipe.common.models import KgTask, DataFormat, Data +from logging import getLogger + +from pydantic import BaseModel +from rdflib import OWL, Graph, URIRef, RDFS, RDF, SKOS +from pathlib import Path +import json +import os +from kgcore.api.ontology import OntologyUtil +from kgpipe.common.config import TARGET_ONTOLOGY_NAMESPACE +from typing import Dict, List +from kgpipe_tasks.entity_resolution.fusion.util import load_matches_from_file + +SINGLE_CANDIDATE_CHECK: bool=False + +logger = getLogger(__name__) + +class TrackRecord(BaseModel): + original_subject: str + subject: str + original_predicate: str + predicate: str + original_object: str + object: str + +def select_first_value(inputs: Dict[str, Data], outputs: Dict[str, Data]): + """ + For two KGs A and B, merge A into B where for each s_p and + 1) p is fusable and B does not have any s_p_o or + 2) p is not fusable erge all s_p_o + """ + ontology_path = os.environ.get("ONTOLOGY_PATH", "false") + if ontology_path == "false": + raise ValueError("ONTOLOGY_PATH is not set") + + ontology = OntologyUtil.load_ontology_from_file(Path(ontology_path)) + allowed_predicates = set[str]([str(p.uri) for p in ontology.properties]+[str(RDFS.label), str(RDF.type), str(SKOS.altLabel)]) + fusable_properties = set[str]([str(p.uri) for p in ontology.properties if p.max_cardinality == 1]+[str(RDFS.label), str(RDF.type)]) + + def is_fusable(p): + return str(p) in fusable_properties + + source_graph = Graph() + source_graph.parse(inputs["source"].path, format="nt") + seed_graph = Graph() # seed graph + seed_graph.parse(inputs["target"].path, format="nt") + + current_subjects = set[str]([str(s) for s in seed_graph.subjects(unique=True)]) + + selected: List[TrackRecord] = [] + discarded: List[TrackRecord] = [] + + for s, p, o in source_graph: + s_can = s + p_can = p + o_can = o + + if not isinstance(p_can, URIRef) or str(p_can) not in allowed_predicates: + continue + + if p_can == RDF.type and not str(o_can).startswith(TARGET_ONTOLOGY_NAMESPACE): + continue + + if is_fusable(p_can): + # Add exactly one value if none exists yet + if not any(seed_graph.objects(s_can, p_can)): + seed_graph.add((s_can, p_can, o_can)) + selected.append( + TrackRecord(subject=s_can,predicate=p_can,object=o,original_subject=s,original_predicate=p,original_object=o)) + # keep subjects set fresh for subsequent matches + if isinstance(s_can, URIRef): + current_subjects.add(str(s_can)) + else: + discarded.append( + TrackRecord(subject=s_can,predicate=p_can,object=o,original_subject=s,original_predicate=p,original_object=o)) + else: + # Non-fusable: copy if not already present (avoid dupes) + if (s_can, p_can, o_can) not in seed_graph: + seed_graph.add((s_can, p_can, o_can)) + if isinstance(s_can, URIRef): + current_subjects.add(str(s_can)) + + # sel(ected) + selected_file_path = outputs["output"].path.parent / (outputs["output"].path.stem + ".selected.json") + with open(selected_file_path, "w") as f: + json.dump(selected, f, default=lambda x: x.model_dump()) + # dis(carded) + discarded_file_path = outputs["output"].path.parent / (outputs["output"].path.stem + ".discarded.json") + with open(discarded_file_path, "w") as f: + json.dump(discarded, f, default=lambda x: x.model_dump()) + + # prov graph is skipped here as no uris are replaced (is done in previouse steps) + seed_graph.serialize(outputs["output"].path, format="nt") + +def fusion_first_value(inputs: Dict[str, Data], outputs: Dict[str, Data], entity_matching_threshold: float, relation_matching_threshold: float, ontology_path: str): + """ + Fuse RDF entities + - replacing ids of target graph with ids of source graph based on matches + - only fusable properties are fused + - selects the first value from source graph if no target value exists (does not add values from target graph) + - also if target graph has multiple values for a property, the first value is selected (for new entities) + """ + ontology = OntologyUtil.load_ontology_from_file(Path(ontology_path)) + allowed_predicates = set[str]([str(p.uri) for p in ontology.properties]+[str(RDFS.label), str(RDF.type), str(SKOS.altLabel)]) + fusable_properties = set[str]([str(p.uri) for p in ontology.properties if p.max_cardinality == 1]+[str(RDFS.label), str(RDF.type)]) + + def is_fusable(p): + return str(p) in fusable_properties + + entity_matches = load_matches_from_file(inputs["matches1"].path, entity_matching_threshold, "entity") + relation_matches = load_matches_from_file(inputs["matches1"].path, relation_matching_threshold, "relation") + + source_graph = Graph() + source_graph.parse(inputs["source"].path, format="nt") + seed_graph = Graph() # seed graph + seed_graph.parse(inputs["kg"].path, format="nt") + + current_subjects = set[str]([str(s) for s in seed_graph.subjects(unique=True)]) + + sameAsProv = {} + + def canonicalize_entity_term(term): + """Map a URI from the target graph to the matching source URI, if any.""" + if isinstance(term, URIRef): + t_str = str(term) + cluster = entity_matches.get_cluster(t_str) + if cluster: + right_candidates = [c for c in cluster if not c == t_str] + if len(right_candidates) > 2 and SINGLE_CANDIDATE_CHECK: + raise ValueError(f"Multiple matches found for {t_str}") + else: + for m in right_candidates: + # if not m == t_str: + sameAsProv[str(term)] = str(m) + return URIRef(m) + return term + else: + return term + return term + + def canonicalize_property_term(term): + """Map a URI from the target graph to the matching source URI, if any.""" + if isinstance(term, URIRef): + t_str = str(term) + mapped = relation_matches.has_match_to_namespace(t_str, TARGET_ONTOLOGY_NAMESPACE) + if mapped: + return URIRef(mapped) + else: # TODO this is a workaround for the base pipelines... + mapped = relation_matches.has_match_to_namespace(t_str, str(RDFS)) + if mapped: + return URIRef(mapped) + return term + + selected: List[TrackRecord] = [] + discarded: List[TrackRecord] = [] + + for s, p, o in source_graph: + # Canonicalize + logger.debug(f"Canonicalizing {s}, {p}, {o}") + s_can = canonicalize_entity_term(s) + p_can = canonicalize_property_term(p) + o_can = canonicalize_entity_term(o) if isinstance(o, URIRef) else o # keep literals/bnodes as-is + + # Only work with properties that are in our ontology (after canonicalization) + if not isinstance(p_can, URIRef) or str(p_can) not in allowed_predicates: + logger.debug(f"Skipping {s}, {p}, {o} because it is not in the allowed predicates") + continue + + if p_can == RDF.type and not str(o_can).startswith(TARGET_ONTOLOGY_NAMESPACE): + continue + + if is_fusable(p_can): + # Add exactly one value if none exists yet + if not any(seed_graph.objects(s_can, p_can)): + seed_graph.add((s_can, p_can, o_can)) + selected.append( + TrackRecord(subject=s_can,predicate=p_can,object=o,original_subject=s,original_predicate=p,original_object=o)) + # keep subjects set fresh for subsequent matches + if isinstance(s_can, URIRef): + current_subjects.add(str(s_can)) + else: + discarded.append( + TrackRecord(subject=s_can,predicate=p_can,object=o,original_subject=s,original_predicate=p,original_object=o)) + else: + # Non-fusable: copy if not already present (avoid dupes) + if (s_can, p_can, o_can) not in seed_graph: + seed_graph.add((s_can, p_can, o_can)) + if isinstance(s_can, URIRef): + current_subjects.add(str(s_can)) + + # sel(ected) + selected_file_path = outputs["output"].path.parent / (outputs["output"].path.stem + ".selected.json") + with open(selected_file_path, "w") as f: + json.dump(selected, f, default=lambda x: x.model_dump()) + # dis(carded) + discarded_file_path = outputs["output"].path.parent / (outputs["output"].path.stem + ".discarded.json") + with open(discarded_file_path, "w") as f: + json.dump(discarded, f, default=lambda x: x.model_dump()) + + prov_graph = Graph() + for sid,gid in sameAsProv.items(): + prov_graph.add((URIRef(gid), OWL.sameAs, URIRef(sid))) + prov_graph.serialize(outputs["output"].path.as_posix() + ".prov", format="nt") + seed_graph.serialize(outputs["output"].path, format="nt") \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/genie.py b/experiments/param-opti/src/param_opti/tasks/genie.py new file mode 100644 index 0000000..1abf7c6 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/genie.py @@ -0,0 +1,14 @@ +from typing import Dict, Any +from kgpipe.common import Data, DataFormat, Registry, KgTask + + +def genie_text_extraction_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): + pass + +genie_text_extraction_task = KgTask( + name="genie_text_extraction", + input_spec={"input": DataFormat.TEXT}, + output_spec={"output": DataFormat.TE_JSON}, + function=genie_text_extraction_function, + description="Extract text using Genie" +) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/genie_lib.py b/experiments/param-opti/src/param_opti/tasks/genie_lib.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/param-opti/src/param_opti/tasks/jedai.py b/experiments/param-opti/src/param_opti/tasks/jedai.py index e69de29..062fe48 100644 --- a/experiments/param-opti/src/param_opti/tasks/jedai.py +++ b/experiments/param-opti/src/param_opti/tasks/jedai.py @@ -0,0 +1 @@ +# Skipped for now \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/matching_helpers.py b/experiments/param-opti/src/param_opti/tasks/matching_helpers.py new file mode 100644 index 0000000..fd301a0 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/matching_helpers.py @@ -0,0 +1,30 @@ +from pathlib import Path + +from kgpipe.common import Data, DataFormat, KgTask +from typing import Dict +from kgpipe_tasks.transform_interop.exchange.entity_matching import ER_Document +import json + + +def _load_er_document(path: Path) -> ER_Document: + """Parse ER JSON; empty or whitespace-only files yield an empty document (stub tasks may touch-only outputs).""" + raw = path.read_text(encoding="utf-8") + if not raw.strip(): + return ER_Document() + return ER_Document(**json.loads(raw)) + + +def aggregate_matching_results_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): + er1 = _load_er_document(Path(inputs["json1"].path)) + er2 = _load_er_document(Path(inputs["json2"].path)) + er_comb = ER_Document(matches=er1.matches + er2.matches) + with open(outputs["output"].path, "w") as f: + json.dump(er_comb.model_dump(), f, indent=4) + + +aggregate_matching_results_task = KgTask( + name="aggregate_matching_results", + input_spec=dict({"json1": DataFormat.ER_JSON, "json2": DataFormat.ER_JSON}), + output_spec=dict({"output": DataFormat.ER_JSON}), + function=aggregate_matching_results_function +) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/paris.py b/experiments/param-opti/src/param_opti/tasks/paris.py index 21dbce9..4fb5772 100644 --- a/experiments/param-opti/src/param_opti/tasks/paris.py +++ b/experiments/param-opti/src/param_opti/tasks/paris.py @@ -1,5 +1,6 @@ -from kgpipe.common import TaskInput, TaskOutput, KgTask, DataFormat +from kgpipe.common import TaskInput, TaskOutput, KgTask, DataFormat, Data from kgpipe.common.model.configuration import ConfigurationProfile, ConfigurationDefinition, Parameter, ParameterType +from pathlib import Path @@ -29,7 +30,27 @@ def paris_graph_alignment_function(inputs: TaskInput, outputs: TaskOutput, confi matches both entities and relations between two RDF graphs """ # touch output file - outputs["output"].path.touch() + from param_opti.tasks.paris_lib import paris_exchange, paris_entity_matching + entity_matching_threshold = float(config.get_parameter_value("entity_matching_threshold")) + relation_matching_threshold = float(config.get_parameter_value("relation_matching_threshold")) + + # Ensure parent directory exists for the ER JSON output file + outputs["output"].path.parent.mkdir(parents=True, exist_ok=True) + + # 1 produce matches in paris csv format + matching_dir = outputs["output"].path.parent / f"{outputs['output'].path.stem}_paris_out" + matching_output = {"output": Data(matching_dir, DataFormat.PARIS_CSV)} + + # paris_entity_matching expects {"source": ..., "kg": ...} + paris_entity_matching({"source": inputs["source"], "kg": inputs["target"]}, matching_output) + + # 2 convert paris output dir to er.json format (file) + paris_exchange( + matching_output["output"].path, + outputs["output"].path, + entity_matching_threshold, + relation_matching_threshold, + ) paris_graph_alignment_task = KgTask( name="paris_graph_alignment", @@ -43,4 +64,24 @@ def paris_graph_alignment_function(inputs: TaskInput, outputs: TaskOutput, confi Parameter(name="relation_matching_threshold", native_keys=["--relation-matching-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), ] ) +) + +def paris_ontology_matching_function(inputs: TaskInput, outputs: TaskOutput, config: ConfigurationProfile): + """ + matches ontologies between two RDF graphs + """ + # touch output file + outputs["output"].path.touch() + +paris_ontology_matching_task = KgTask( + name="paris_ontology_matching", + function=paris_ontology_matching_function, + input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES}, + output_spec={"output": DataFormat.ER_JSON}, + config_spec=ConfigurationDefinition( + name="paris_ontology_matching", + parameters=[ + Parameter(name="ontology_matching_threshold", native_keys=["--ontology-matching-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), + ] + ) ) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/paris_lib.py b/experiments/param-opti/src/param_opti/tasks/paris_lib.py new file mode 100644 index 0000000..2aa5fd5 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/paris_lib.py @@ -0,0 +1,152 @@ +""" +Paris RDF Matcher task implementation. +""" + +from pathlib import Path +from typing import Dict, Any +import pandas as pd +import os +import csv +from typing import List + +from kgpipe.common import KgTask, DataFormat, Data, Registry +from kgpipe.common.io import get_docker_volume_bindings, remap_data_path_for_container +from kgpipe.execution import docker_client +from kgpipe_tasks.transform_interop.exchange.entity_matching import ER_Match, ER_Document + + +def paris_entity_matching(inputs: Dict[str, Data], outputs: Dict[str, Data]): + """ + Paris entity matching task that runs in a Docker container. + + Args: + inputs: Dictionary mapping input names to Data objects + outputs: Dictionary mapping output names to Data objects + """ + # print(f"Running Paris entity matching with inputs: {inputs}") + + all_data = list(inputs.values()) + list(outputs.values()) + volumes, host_to_container = get_docker_volume_bindings(all_data) + + # Extract input paths + source_path = remap_data_path_for_container(inputs["source"], host_to_container) + target_path = remap_data_path_for_container(inputs["kg"], host_to_container) + output_path = remap_data_path_for_container(outputs["output"], host_to_container) + + # Ensure output directory exists + outputs["output"].path.parent.mkdir(parents=True, exist_ok=True) + + # Get all data for Docker volume bindings + + # Create Docker client with proper volume bindings + client = docker_client( + image="kgt/paris:latest", + # command=["ls", "-la"], + command=["bash", "paris.sh", + str(source_path.path), + str(target_path.path), + str(output_path.path)], + volumes=volumes, + ) + + # Execute the container + result = client() + print(f"Paris entity matching completed: {result}") + + +PREFIX_MAP = { + "dbp": "http://dbpedia.org/", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "rdf" : "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "xsd" : "http://www.w3.org/2001/XMLSchema#", + "schema" : "http://schema.org/", + "dbo": "http://dbpedia.org/ontology/", + "foaf": "http://xmlns.com/foaf/0.1/", + "skos": "http://www.w3.org/2004/02/skos/core#", +} + +def resolvePrefixedUri(uri): + if not uri.startswith("http://") and not uri.startswith("https://"): + prefix, suffix = uri.split(":", 1) + # try: + prefix = PREFIX_MAP[prefix] + # except Exception as e: + # print(f"Unknown prefix: {prefix} for {uri}") + # raise Exception(f"Unknown prefix: {prefix} for {uri}") + return prefix + suffix + else: + return uri + + + +def paris_exchange(input_path: Path, output_path: Path, entity_matching_threshold: float, relation_matching_threshold: float): + """ + Convert Paris CSV output to standard RDF matching format. + + Args: + inputs: Dictionary mapping input names to Data objects (Paris CSV) + outputs: Dictionary mapping output names to Data objects (RDF) + """ + print(f"Converting Paris CSV to matching format with input_path: {input_path} and output_path: {output_path}") + + files = [str(f) for f in os.listdir(input_path)] + + iteration_ids = [ int(f.split("_")[0]) for f in files if f.endswith(".tsv") ] + + iteration_ids.sort() + + last_eqv_it = iteration_ids[-1] + + def getEqvFileName(id): return f"{id}_eqv.tsv" + def getRelFileNames(id): return [f"{id}_superrelations1.tsv",f"{id}_superrelations2.tsv"] + + def check_file_exists(last_eqv_it): + try: + return os.stat(os.path.join(input_path, getEqvFileName(last_eqv_it))).st_size > 0 + except FileNotFoundError: + return -1 + + while 0 == check_file_exists(last_eqv_it) : + last_eqv_it -= 1 + + last_relation_it = last_eqv_it - 1 + + matches : List[ER_Match] = [] + + def extract_matches(file,id_type): + with open(file, newline='', encoding='utf-8') as csvfile: + reader = csv.reader(csvfile, delimiter='\t') + for row in reader: + if len(row) == 3: + er_match = ER_Match( + id_1=resolvePrefixedUri(row[0]), + id_2=resolvePrefixedUri(row[1]), + score=float(row[2]), + id_type=id_type + ) + matches.append(er_match) + + def filter_matches(matches: List[ER_Match]): + + for match in matches: + if match.id_type == "entity" and match.score > entity_matching_threshold: + yield match + if match.id_type == "relation" and match.score > relation_matching_threshold: + yield match + + if last_eqv_it == -1: + doc = ER_Document(matches=list(filter_matches([]))) + with open(output_path, 'w', encoding='utf-8') as jsonfile: + jsonfile.write(doc.model_dump_json()) + else: + eqv_file = getEqvFileName(last_eqv_it) + rel_files = getRelFileNames(last_relation_it) + + extract_matches(os.path.join(input_path,eqv_file),"entity") + [ extract_matches(os.path.join(input_path,f), "relation") for f in rel_files ] + + + doc = ER_Document(matches=list(filter_matches(matches))) + + with open(output_path, 'w', encoding='utf-8') as jsonfile: + jsonfile.write(doc.model_dump_json()) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/select_lib.py b/experiments/param-opti/src/param_opti/tasks/select_lib.py new file mode 100644 index 0000000..7dd390f --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/select_lib.py @@ -0,0 +1,119 @@ +import json +import os +from logging import getLogger +from pathlib import Path +from typing import Dict, List + +from kgcore.api.ontology import OntologyUtil +from kgpipe.common.config import TARGET_ONTOLOGY_NAMESPACE +from kgpipe.common.model.configuration import ConfigurationDefinition +from kgpipe.common.models import Data, DataFormat, KgTask +from pydantic import BaseModel +from rdflib import Graph, RDF, RDFS, SKOS, URIRef + +logger = getLogger(__name__) + +class TrackRecord(BaseModel): + original_subject: str + subject: str + original_predicate: str + predicate: str + original_object: str + object: str + + +def select_first_value_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): + """ + For two KGs A and B, merge A into B where for each s_p and + 1) p is fusable and B does not have any s_p_o or + 2) p is not fusable erge all s_p_o + """ + ontology_path = os.environ.get("ONTOLOGY_PATH", "false") + if ontology_path == "false": + raise ValueError("ONTOLOGY_PATH is not set") + + ontology = OntologyUtil.load_ontology_from_file(Path(ontology_path)) + allowed_predicates = set[str]([str(p.uri) for p in ontology.properties]+[str(RDFS.label), str(RDF.type), str(SKOS.altLabel)]) + fusable_properties = set[str]([str(p.uri) for p in ontology.properties if p.max_cardinality == 1]+[str(RDFS.label), str(RDF.type)]) + + def is_fusable(p): + return str(p) in fusable_properties + + source_graph = Graph() + source_graph.parse(inputs["source"].path, format="nt") + seed_graph = Graph() # seed graph + seed_graph.parse(inputs["target"].path, format="nt") + + current_subjects = set[str]([str(s) for s in seed_graph.subjects(unique=True)]) + + selected: List[TrackRecord] = [] + discarded: List[TrackRecord] = [] + + for s, p, o in source_graph: + s_can = s + p_can = p + o_can = o + + if not isinstance(p_can, URIRef) or str(p_can) not in allowed_predicates: + continue + + if p_can == RDF.type and not str(o_can).startswith(TARGET_ONTOLOGY_NAMESPACE): + continue + + if is_fusable(p_can): + # Add exactly one value if none exists yet + if not any(seed_graph.objects(s_can, p_can)): + seed_graph.add((s_can, p_can, o_can)) + selected.append( + TrackRecord( + subject=str(s_can), + predicate=str(p_can), + object=str(o_can), + original_subject=str(s), + original_predicate=str(p), + original_object=str(o), + ) + ) + # keep subjects set fresh for subsequent matches + if isinstance(s_can, URIRef): + current_subjects.add(str(s_can)) + else: + discarded.append( + TrackRecord( + subject=str(s_can), + predicate=str(p_can), + object=str(o_can), + original_subject=str(s), + original_predicate=str(p), + original_object=str(o), + ) + ) + else: + # Non-fusable: copy if not already present (avoid dupes) + if (s_can, p_can, o_can) not in seed_graph: + seed_graph.add((s_can, p_can, o_can)) + if isinstance(s_can, URIRef): + current_subjects.add(str(s_can)) + + # sel(ected) + selected_file_path = outputs["output"].path.parent / (outputs["output"].path.stem + ".selected.json") + with open(selected_file_path, "w") as f: + json.dump(selected, f, default=lambda x: x.model_dump()) + # dis(carded) + discarded_file_path = outputs["output"].path.parent / (outputs["output"].path.stem + ".discarded.json") + with open(discarded_file_path, "w") as f: + json.dump(discarded, f, default=lambda x: x.model_dump()) + + # prov graph is skipped here as no uris are replaced (is done in previouse steps) + seed_graph.serialize(outputs["output"].path, format="nt") + +select_first_value_task = KgTask( + name="select_first_value", + input_spec={"source": DataFormat.RDF_NTRIPLES, "target": DataFormat.RDF_NTRIPLES}, + output_spec={"output": DataFormat.RDF_NTRIPLES}, + function=select_first_value_function, + config_spec=ConfigurationDefinition( + name="select_first_value", + parameters=[] + ) +) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/spotlight.py b/experiments/param-opti/src/param_opti/tasks/spotlight.py index e69de29..213cef4 100644 --- a/experiments/param-opti/src/param_opti/tasks/spotlight.py +++ b/experiments/param-opti/src/param_opti/tasks/spotlight.py @@ -0,0 +1,23 @@ +from typing import Dict, Any +from kgpipe.common import Data, DataFormat, Registry, KgTask +from kgpipe.common.model.configuration import ConfigurationDefinition, Parameter, ParameterType + +def spotlight_entity_linking_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): + from param_opti.tasks.spotlight_lib import dbpedia_spotlight_ner_nel, dbpedia_spotlight_exchange_filtered + + dbpedia_spotlight_ner_nel({"input": inputs["input"]}, {"output": outputs["output"]}) + dbpedia_spotlight_exchange_filtered({"source": outputs["output"]}, {"output": outputs["output"]}) + +spotlight_entity_linking_task = KgTask( + name="spotlight_entity_linking", + input_spec={"input": DataFormat.TEXT}, + output_spec={"output": DataFormat.TE_JSON}, + function=spotlight_entity_linking_function, + description="Link entities using Spotlight", + config_spec=ConfigurationDefinition( + name="spotlight_entity_linking", + parameters=[ + Parameter(name="similarity_threshold", native_keys=["--similarity-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), + ] + ) +) \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/spotlight_lib.py b/experiments/param-opti/src/param_opti/tasks/spotlight_lib.py new file mode 100644 index 0000000..277de80 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/spotlight_lib.py @@ -0,0 +1,187 @@ +""" +DBpedia Spotlight Entity Linking + +This module provides entity linking using DBpedia Spotlight. +""" + +import json +import os +import requests +from pathlib import Path +from typing import Dict, Any + +from kgpipe.common import KgTask, Data, DataFormat, Registry +from kgpipe.common.io import get_docker_volume_bindings +from kgpipe.execution import docker_client +from tqdm import tqdm + +import os + + +CONFIDENCE = 0.35 +HEADERS = { + "Accept": "application/json" +} + + +def api_request(url: str, text: str) -> Dict[str, Any]: + """Make API request to DBpedia Spotlight.""" + data = { + "text": text, + "confidence": str(CONFIDENCE) + } + response = requests.post(url, data=data, headers=HEADERS, verify=False) + + if response.status_code == 200: + result = response.json() + else: + result = { + "error": f"Request failed with status code {response.status_code}", + "text": text + } + return result + + +@Registry.task( + input_spec={"input": DataFormat.TEXT}, + output_spec={"output": DataFormat.SPOTLIGHT_JSON}, + description="Link entities using DBpedia Spotlight API", + category=["TextProcessing", "EntityLinking"] +) +def dbpedia_spotlight_ner_nel(inputs: Dict[str, Data], outputs: Dict[str, Data]): + """Link entities using DBpedia Spotlight API.""" + input_data = inputs["input"] + output_data = outputs["output"] + + DBPEDIA_ANNOTATE_URL = os.getenv("DBPEDIA_ANNOTATE_URL") + if not DBPEDIA_ANNOTATE_URL: + raise ValueError("Missing DBpedia ANnotate URL") + + dir_or_file = input_data.path + if os.path.isdir(dir_or_file): + os.makedirs(output_data.path, exist_ok=True) + for file in tqdm(os.listdir(dir_or_file)): + with open(os.path.join(dir_or_file, file), encoding='utf-8') as f: + input_text = f.read() + + results = api_request(DBPEDIA_ANNOTATE_URL, input_text) + + with open(os.path.join(output_data.path, file+".json"), 'w', encoding='utf-8') as f: + f.write(json.dumps(results)) + # print(f"Converted {file} to {os.path.join(output_data.path, file)}") + else: + with open(input_data.path, encoding='utf-8') as f: + input_text = f.read() + + results = api_request(DBPEDIA_ANNOTATE_URL, input_text) + + with open(output_data.path, 'w', encoding='utf-8') as f: + f.write(json.dumps(results)) + + +@Registry.task( + input_spec={"source": DataFormat.SPOTLIGHT_JSON}, + output_spec={"output": DataFormat.TE_JSON}, + description="Convert Spotlight JSON to TE JSON format", + category=["TextProcessing", "EntityLinking"] +) +def dbpedia_spotlight_exchange_filtered(inputs: Dict[str, Data], outputs: Dict[str, Data]): + """Convert Spotlight JSON to TE JSON format.""" + input_path = inputs["source"].path + output_path = outputs["output"].path + + + + # create output folder + os.makedirs(os.path.normpath(output_path), exist_ok=True) + + def __spotlightjson2tejson(data) -> Dict[str, Any]: + """Convert Spotlight JSON to TE Document format.""" + links = [] + + for result in data.get('Resources', []): + link = { + "span": result.get('@surfaceForm', ''), + "mapping": result.get('@URI', ''), + "score": float(result.get('@similarityScore', 0.0)), + "link_type": "entity" + } + links.append(link) + + text = data.get('@text', '') + return {"text": text, "links": links} + + if os.path.isdir(input_path): + for file in os.listdir(input_path): + # Read input json + with open(os.path.join(input_path, file), 'r') as f: + data = json.load(f) + te_doc = __spotlightjson2tejson(data) + outfile = os.path.join(output_path, file) + + with open(outfile, 'w') as of: + json.dump(te_doc, of) + # print(f"Converted {file} to {outfile}") + + else: + # Read input json + with open(input_path, 'r') as f: + data = json.load(f) + te_doc = __spotlightjson2tejson(data) + outfile = os.path.join(output_path, 'output.te.json') + with open(outfile, 'w') as of: + json.dump(te_doc, of) + # print(f"Converted {input_path} to {output_path}") + + +@Registry.task( + input_spec={"source": DataFormat.SPOTLIGHT_JSON}, + output_spec={"output": DataFormat.TE_JSON}, + description="Convert Spotlight JSON to TE JSON format, with seed filter", + category=["TextProcessing", "EntityLinking"] +) +def dbpedia_spotlight_exchange(inputs: Dict[str, Data], outputs: Dict[str, Data]): + """Convert Spotlight JSON to TE JSON format.""" + input_path = inputs["source"].path + output_path = outputs["output"].path + + # create output folder + os.makedirs(os.path.normpath(output_path), exist_ok=True) + + def __spotlightjson2tejson(data) -> Dict[str, Any]: + """Convert Spotlight JSON to TE Document format.""" + links = [] + + for result in data.get('Resources', []): + link = { + "span": result.get('@surfaceForm', ''), + "mapping": result.get('@URI', ''), + "score": float(result.get('@similarityScore', 0.0)), + "link_type": "entity" + } + links.append(link) + + text = data.get('@text', '') + return {"text": text, "links": links} + + if os.path.isdir(input_path): + for file in os.listdir(input_path): + # Read input json + with open(os.path.join(input_path, file), 'r') as f: + data = json.load(f) + te_doc = __spotlightjson2tejson(data) + outfile = os.path.join(output_path, file) + + with open(outfile, 'w') as of: + json.dump(te_doc, of) + # print(f"Converted {file} to {outfile}") + + else: + # Read input json + with open(input_path, 'r') as f: + data = json.load(f) + te_doc = __spotlightjson2tejson(data) + outfile = os.path.join(output_path, 'output.te.json') + with open(outfile, 'w') as of: + json.dump(te_doc, of) + \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/text_helpers.py b/experiments/param-opti/src/param_opti/tasks/text_helpers.py new file mode 100644 index 0000000..4f5d776 --- /dev/null +++ b/experiments/param-opti/src/param_opti/tasks/text_helpers.py @@ -0,0 +1,348 @@ +import json +import logging +import os +from pathlib import Path +from typing import Dict, List + +from kgcore.api.ontology import Ontology, OntologyUtil +from kgpipe.common import Data, DataFormat, KgTask +from kgpipe.common.model.configuration import ConfigurationDefinition +from kgpipe_tasks.common.benchutils import hash_uri +from kgpipe_tasks.transform_interop.exchange.text_extraction import ( + TE_Chains, + TE_Document, + TE_Pair, + TE_Triple, +) +from rdflib import Graph, Literal, RDF, RDFS, URIRef, XSD + +logger = logging.getLogger(__name__) + +def __aggregate_x_te_json(input_paths: List[Path], output_path: Path): + + if len(input_paths) == 0: + raise Exception("No input paths provided") + if not all(os.path.exists(path) for path in input_paths): + raise Exception("All input paths must exist") + + path_is_dir_list = [os.path.isdir(path) for path in input_paths] + if all(path_is_dir_list): + os.makedirs(output_path, exist_ok=True) + for file in os.listdir(input_paths[0]): + sub_file_paths = [Path(os.path.join(path, file)) for path in input_paths] + file_exists = [os.path.exists(path) for path in sub_file_paths] + if all(file_exists): + __aggregate_x_te_json(sub_file_paths, Path(os.path.join(output_path, file))) + else: + logger.warning(f"File {file} does not exist in all input paths") + filtered_sub_file_paths = [path for path in sub_file_paths if os.path.exists(path)] + __aggregate_x_te_json(filtered_sub_file_paths, Path(os.path.join(output_path, file))) + elif not all(path_is_dir_list): + merged_doc = TE_Document() + for file in input_paths: + doc = TE_Document(**json.load(open(file))) + merged_doc.chains += doc.chains + merged_doc.links += doc.links + merged_doc.triples += doc.triples + with open(output_path, "w") as f: + f.write(merged_doc.model_dump_json()) + logger.info(f"Aggregated {", ".join([str(path) for path in input_paths])} to {output_path}") + else: + raise Exception("All inputs must be either directories or files") + + +# @Registry.task( +# input_spec={"json1": DataFormat.TE_JSON, "json2": DataFormat.TE_JSON}, +# output_spec={"output": DataFormat.TE_JSON}, +# description="Aggregate 2 TE_Document JSON files", +# category=["Aggregation"] +# ) +# def aggregate2_te_json(inputs: Dict[str, Data], outputs: Dict[str, Data]): +# __aggregate_x_te_json([inputs["json1"].path, inputs["json2"].path], outputs["output"].path) + + +def aggregate3_text_tasks_task_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): + __aggregate_x_te_json([inputs["json1"].path, inputs["json2"].path, inputs["json3"].path], outputs["output"].path) + +aggregate_text_tasks_task = KgTask( + name="aggregate3_text_tasks_task", + input_spec={"json1": DataFormat.TE_JSON, "json2": DataFormat.TE_JSON, "json3": DataFormat.TE_JSON}, + output_spec={"output": DataFormat.TE_JSON}, + function=aggregate3_text_tasks_task_function +) + + +def generatePredicate(surface_form, namespace): + return URIRef(namespace + surface_form.replace(" ", "_")) + +def __generateRDF(doc: TE_Document, ontology: Ontology, newP: bool = False, newE: bool = False, namespace: str = "http://kg.org/text/"): + """ + A processing node, part of a pipeline + collects information from extractors, linkers, and resolvers and then it produces the final triples + """ + + + def process_chains(triples, chains: List[TE_Chains]): + new_triples = triples + chain_dict = {} + for chain in chains: + for alias in chain.aliases: + chain_dict[alias.surface_form] = chain.main + if len(chain_dict) > 0: + # TODO check if chain_dict should be a dict of TE_SPANS to avoid merging + for triple in new_triples: + if triple.subject.surface_form in chain_dict: + triple.subject.surface_form = chain_dict[triple.subject.surface_form] + if triple.object.surface_form in chain_dict: + triple.object.surface_form = chain_dict[triple.object.surface_form] + return new_triples + + def process_links(triples, links: List[TE_Pair]): + new_triples = triples + try: + if len(links) > 0: + so_spans = {} + p_spans = {} + for triple in triples: + # Add Subject spans + if triple.subject.surface_form.lower().startswith("http://"): + pass + elif triple.subject.surface_form.lower() not in so_spans: + so_spans[triple.subject.surface_form.lower()] = [triple.subject] + else: + so_spans[triple.subject.surface_form.lower()].append(triple.subject) + # Add object spans + if triple.object.surface_form.lower().startswith("http://"): + pass + elif triple.object.surface_form.lower() not in so_spans: + so_spans[triple.object.surface_form.lower()] = [triple.object] + else: + so_spans[triple.object.surface_form.lower()].append(triple.object) + # add predicate spans + if triple.predicate.surface_form.lower().startswith("http://"): + pass + elif triple.predicate.surface_form.lower() not in p_spans: + p_spans[triple.predicate.surface_form.lower()] = [triple.predicate] + else: + p_spans[triple.predicate.surface_form.lower()].append(triple.predicate) + for link in links: + if link.link_type == 'entity': + spans = so_spans + else: + spans = p_spans + if link.span and link.span.lower() in spans: + for span in spans[link.span.lower()]: + span.mapping = link.mapping + # span.surface_form = link.mapping + except Exception as exp: + raise exp + finally: + return new_triples + + + triples: List[TE_Triple] = doc.triples + links: List[TE_Pair] = doc.links + chains: List[TE_Chains] = doc.chains + + dereferenced_tiples = process_chains(triples, chains) + linked_triples: List[TE_Triple] = process_links(dereferenced_tiples, links) + finalGraph = Graph() + + for triple in linked_triples: + subject = None + if triple.subject.mapping: + subject = URIRef(triple.subject.mapping) + # else: + # subject = triple.subject.surface_form + + predicate = None + if triple.predicate.mapping: + predicate = URIRef(triple.predicate.mapping) + elif newP: + predicate = generatePredicate(triple.predicate.surface_form, namespace) + + object = None + # TODO if predicate is a datatype or object property + if triple.object.mapping: + object = URIRef(triple.object.mapping) + # else: + # object = Literal(triple.object.surface_form) + # if(subject and predicate and object): + # finalGraph.add((subject, predicate, object)) + + # new entities + if(predicate): + # print(f"new subject: {subject} {triple.subject.surface_form}") + + domain, range = ontology.get_domain_range(str(predicate)) + isObjectProperty = True if range and range.startswith("http://kg.org") else False + # print(f"predicate: {predicate}, domain: {domain}, range: {range}, isObjectProperty: {isObjectProperty}") + # print(f"predicate: {predicate}, domain: {domain}, range: {range}") + + if subject and subject.startswith("http://dbpedia.org"): # TODO workaround for dbpedia... + finalGraph.add((subject, RDFS.label, Literal(triple.subject.surface_form))) + + + if not subject and triple.subject.surface_form and newE: + subject = URIRef(namespace+hash_uri(triple.subject.surface_form)) + finalGraph.add((subject, RDFS.label, Literal(triple.subject.surface_form))) + print(f"new subject: {subject} {triple.subject.surface_form}") + else: + print(f"subject: {subject} {triple.subject.surface_form}") + + if domain and subject: + finalGraph.add((subject, RDF.type, URIRef(domain))) + + if not object and triple.object.surface_form and newE: + if isObjectProperty: + object = URIRef(namespace+hash_uri(triple.object.surface_form)) + finalGraph.add((object, RDFS.label, Literal(triple.object.surface_form))) + if range: + finalGraph.add((object, RDF.type, URIRef(range))) + else: + datatype = range if range else str(XSD.string) + object = Literal(triple.object.surface_form, datatype=datatype) + else: + if not isObjectProperty: + datatype = range if range else str(XSD.string) + object = Literal(triple.object.surface_form, datatype=datatype) + + if(subject and predicate and object): + finalGraph.add((subject, predicate, object)) + + return finalGraph + + +def generate_rdf(inputs: Dict[str, Data], outputs: Dict[str, Data], ontology: Ontology, newP: bool, newE: bool): + dir_or_file = inputs["source"].path + graph = Graph() + if os.path.isdir(dir_or_file): + for file in os.listdir(dir_or_file): + json_data = json.load(open(os.path.join(dir_or_file, file))) + doc = TE_Document(**json_data) + for s, p, o in __generateRDF(doc, ontology, newP=newP, newE=newE): + graph.add(triple=(s, p, o)) + else: + doc = TE_Document(**json.load(open(dir_or_file))) + graph = __generateRDF(doc, ontology, newP=newP, newE=newE) + + graph.serialize(outputs["output"].path, format="nt") + print(f"RDF written to {outputs['output'].path}") + + +def generate_rdf_from_text_results_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): + + ontology_path = os.environ.get("ONTOLOGY_PATH", "false") + if ontology_path == "false": + raise ValueError("ONTOLOGY_PATH is not set") + + ontology = OntologyUtil.load_ontology_from_file(Path(ontology_path)) + + generate_rdf(inputs, outputs, ontology, newP=False, newE=True) + + +generate_rdf_from_text_results_task = KgTask( + name="construct_rdf_from_text_tasks_task", + input_spec={"source": DataFormat.TE_JSON}, + output_spec={"output": DataFormat.RDF_NTRIPLES}, + function=generate_rdf_from_text_results_function +) + + +# ------------------------------------------------------------ + + +# def aggregate_3iejson_with_filter(inputs: Dict[str, Data], outputs: Dict[str, Data]): +# json1_path = inputs["json1"].path +# json2_path = inputs["json2"].path +# json3_path = inputs["json3"].path + +# def load_kg_uris_from_shades(): +# """ +# Loads the URIs of the entities in the current KG. +# """ +# shade_file = "/home/marvin/project/data/current/shade_seed.json" +# with open(shade_file, "r") as f: +# return json.load(f) + +# shade_dict = load_kg_uris_from_shades() +# reverse_shade_dict = {v: k for k, v in shade_dict.items()} +# kg_uris = set(shade_dict.values()) + + +# def filter_ie_doc(doc: TE_Document): +# """ +# Removes links to entities that are not in the current KG. +# """ + +# # for uri in kg_uris: +# # print(uri) + +# # Create a new list instead of modifying while iterating +# filtered_links = [] +# for link in doc.links: +# if link.link_type == "entity": +# if link.mapping not in kg_uris: +# # print(f"Removing entity link to {link.mapping} because it is not in the current KG") +# continue # Skip this link +# else: +# tmp = link.mapping +# try: +# link.mapping = reverse_shade_dict[tmp] +# # print(f"Replacing entity link {tmp} with {link.mapping}") +# except KeyError: +# print(f"KeyError: {tmp} not found in reverse_shade_dict, skipping") +# continue # Skip this link +# # elif link.link_type == "relation": +# # if link.mapping not in kg_uris: +# # print(f"Removing relation link to {link.mapping} because it is not in the current KG") +# # continue # Skip this link + +# # Add the link to the filtered list (either it passed all checks or it's not an entity link) +# filtered_links.append(link) + +# doc.links = filtered_links +# return doc + + +# if os.path.isdir(json1_path) and os.path.isdir(json2_path) and os.path.isdir(json3_path): +# # list files in each directory +# json1_files = set(os.listdir(json1_path)) +# json2_files = set(os.listdir(json2_path)) +# json3_files = set(os.listdir(json3_path)) + +# # check for mismatches +# if json1_files == json2_files == json3_files: +# os.makedirs(outputs["output"].path, exist_ok=True) +# for file in json1_files: +# json1_doc = TE_Document(**json.load(open(os.path.join(json1_path, file)))) +# json2_doc = TE_Document(**json.load(open(os.path.join(json2_path, file)))) +# json3_doc = TE_Document(**json.load(open(os.path.join(json3_path, file)))) + +# merged_doc = TE_Document() +# merged_doc.chains = json1_doc.chains + json2_doc.chains + json3_doc.chains +# merged_doc.links = json1_doc.links + json2_doc.links + json3_doc.links +# merged_doc.triples = json1_doc.triples + json2_doc.triples + json3_doc.triples + +# merged_doc = filter_ie_doc(merged_doc) + +# with open(os.path.join(outputs["output"].path, file), "w") as f: +# f.write(merged_doc.model_dump_json()) +# # print(f"Converted {file} to {os.path.join(outputs['output'].path, file)}") +# else: +# print("File mismatch detected:") +# print("Files only in json1:", json1_files - json2_files - json3_files) +# print("Files only in json2:", json2_files - json1_files - json3_files) +# print("Files only in json3:", json3_files - json1_files - json2_files) +# print("Common files in all:", json1_files & json2_files & json3_files) +# raise Exception("All input directories must contain the same file names") +# else: +# raise Exception("All inputs must be directories") + +# aggregate_3iejson_with_filter_task = KgTask( +# name="aggregate_iejson_with_filter_task", +# input_spec={"json1": DataFormat.TE_JSON, "json2": DataFormat.TE_JSON, "json3": DataFormat.TE_JSON}, +# output_spec={"output": DataFormat.TE_JSON}, +# function=aggregate_3iejson_with_filter +# ) + diff --git a/experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json b/experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json new file mode 100644 index 0000000..a4e16a7 --- /dev/null +++ b/experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json @@ -0,0 +1,175 @@ +{ + "samples": [ + { + "profiles": { + "graph_alignment_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "sentence-transformers/all-mpnet-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.8 + } + ], + "profile_name": "graph_alignment_label_alias_embedding_transformer_model_name=sentence-transformers/all-mpnet-base-v2,similarity_threshold=0.8" + } + }, + "task_keys": [ + "graph_alignment_label_alias_embedding_transformer_task", + "fusion_first_value_task" + ] + }, + { + "profiles": { + "entity_matcher_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "sentence-transformers/all-mpnet-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.8 + } + ], + "profile_name": "entity_matcher_label_alias_embedding_transformer_model_name=sentence-transformers/all-mpnet-base-v2,similarity_threshold=0.8" + }, + "relation_matcher_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "sentence-transformers/all-MiniLM-L6-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.7 + } + ], + "profile_name": "relation_matcher_label_alias_embedding_transformer_model_name=sentence-transformers/all-MiniLM-L6-v2,similarity_threshold=0.7" + } + }, + "task_keys": [ + "relation_matcher_label_alias_embedding_transformer_task", + "entity_matcher_label_alias_embedding_transformer_task", + "aggregate_matching_results_task", + "fusion_first_value_task" + ] + }, + { + "profiles": { + "paris_entity_alignment": { + "bindings": [ + { + "parameter": "entity_matching_threshold", + "value": 0.7 + } + ], + "profile_name": "paris_entity_alignment_entity_matching_threshold=0.7" + }, + "relation_matcher_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "sentence-transformers/all-mpnet-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.8 + } + ], + "profile_name": "relation_matcher_label_alias_embedding_transformer_model_name=sentence-transformers/all-mpnet-base-v2,similarity_threshold=0.8" + } + }, + "task_keys": [ + "relation_matcher_label_alias_embedding_transformer_task", + "paris_entity_alignment_task", + "aggregate_matching_results_task", + "fusion_first_value_task" + ] + }, + { + "profiles": { + "entity_matcher_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "sentence-transformers/all-MiniLM-L6-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.9 + } + ], + "profile_name": "entity_matcher_label_alias_embedding_transformer_model_name=sentence-transformers/all-MiniLM-L6-v2,similarity_threshold=0.9" + }, + "paris_ontology_matching": { + "bindings": [ + { + "parameter": "ontology_matching_threshold", + "value": 0.9 + } + ], + "profile_name": "paris_ontology_matching_ontology_matching_threshold=0.9" + } + }, + "task_keys": [ + "paris_ontology_matching_task", + "entity_matcher_label_alias_embedding_transformer_task", + "aggregate_matching_results_task", + "fusion_first_value_task" + ] + }, + { + "profiles": { + "paris_entity_alignment": { + "bindings": [ + { + "parameter": "entity_matching_threshold", + "value": 0.7 + } + ], + "profile_name": "paris_entity_alignment_entity_matching_threshold=0.7" + }, + "paris_ontology_matching": { + "bindings": [ + { + "parameter": "ontology_matching_threshold", + "value": 0.6 + } + ], + "profile_name": "paris_ontology_matching_ontology_matching_threshold=0.6" + } + }, + "task_keys": [ + "paris_ontology_matching_task", + "paris_entity_alignment_task", + "aggregate_matching_results_task", + "fusion_first_value_task" + ] + }, + { + "profiles": { + "paris_graph_alignment": { + "bindings": [ + { + "parameter": "entity_matching_threshold", + "value": 0.6 + }, + { + "parameter": "relation_matching_threshold", + "value": 0.5 + } + ], + "profile_name": "paris_graph_alignment_entity_matching_threshold=0.6,relation_matching_threshold=0.5" + } + }, + "task_keys": [ + "paris_graph_alignment_task", + "fusion_first_value_task" + ] + } + ], + "version": 1 +} diff --git a/experiments/param-opti/src/qap/sge_metrics.py b/experiments/param-opti/src/qap/sge_metrics.py new file mode 100644 index 0000000..b4e0e33 --- /dev/null +++ b/experiments/param-opti/src/qap/sge_metrics.py @@ -0,0 +1,17 @@ +from kg_sge.api.correctness import SourceGroundCorrectenss, SourceGroundCorrectnessConfig +from kg_sge.api.coverage import SourceGroundedCoverage, SourceGroundedCoverageConfig +from kgpipe_eval.utils.kg_utils import KgManager, KG, KgLike + +class SourceGroundedCorrectnessMetric: + def __init__(self): + self.correctness = SourceGroundCorrectenss() + + def compute(self, kg: KG, config: SourceGroundCorrectnessConfig): + pass + +class SourceGroundedCoverageMetric: + def __init__(self): + self.coverage = SourceGroundedCoverage() + + def compute(self, kg: KG, config: SourceGroundedCoverageConfig): + pass \ No newline at end of file diff --git a/experiments/param-opti/src/qap/test_exec_pipelines.py b/experiments/param-opti/src/qap/test_exec_pipelines.py new file mode 100644 index 0000000..2ae5626 --- /dev/null +++ b/experiments/param-opti/src/qap/test_exec_pipelines.py @@ -0,0 +1,170 @@ +from kgpipe.common import KgPipe, Data, DataFormat +from kgpipe.common.model.configuration import ConfigurationProfile, ParameterBinding, ConfigurationDefinition +from param_opti.tasks.paris import paris_graph_alignment_task, paris_entity_alignment_task, paris_ontology_matching_task +from param_opti.tasks.fusion import fusion_first_value_task +from param_opti.tasks.base_linker import relation_linker_label_alias_embedding_transformer_task, entity_linker_label_alias_embedding_transformer_task +from param_opti.tasks.corenlp import corenlp_text_extraction_task +from param_opti.tasks.genie import genie_text_extraction_task +from param_opti.tasks.spotlight import spotlight_entity_linking_task +from param_opti.tasks.text_helpers import aggregate_text_tasks_task, generate_rdf_from_text_results_task +from param_opti.tasks.select_lib import select_first_value_task +from qap.test_pipeline_config import ( + PipelineConfig, + _get_param, + load_rdf_sampled_pipeline_configs, +) +from pathlib import Path +import pytest +import os + +from dotenv import load_dotenv +load_dotenv() + +tmp_base_dir = Path("tmp") +if not tmp_base_dir.exists(): + tmp_base_dir.mkdir(parents=True, exist_ok=True) + + +ontology_path = "data/input_final/target_kg/ontology.ttl" +os.environ["ONTOLOGY_PATH"] = ontology_path + + +def get_default_rdf_pipeline_config() -> PipelineConfig: + return PipelineConfig( + tasks=[ + paris_graph_alignment_task, + fusion_first_value_task, + ], + config_catalog={ + # Key must match KgTask.name because KgPipe delegates by task.name + "paris_graph_alignment": ConfigurationProfile( + name="paris_graph_alignment", + definition=paris_graph_alignment_task.config_spec, + bindings=[ + ParameterBinding(parameter=_get_param(paris_graph_alignment_task.config_spec, "entity_matching_threshold"), value=0.5), + ParameterBinding(parameter=_get_param(paris_graph_alignment_task.config_spec, "relation_matching_threshold"), value=0.5), + ], + ) + }, + ) + +def test_rdf_pipeline_from_default_config(): + pipeline_config = get_default_rdf_pipeline_config() + + seed_path = tmp_base_dir / "seed.nt" + source_path = tmp_base_dir / "source.nt" + result_path = tmp_base_dir / "result.nt" + tasks_tmp_dir = tmp_base_dir / "tasks_tmp" + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) + + # Ensure inputs exist for pipeline execution. + seed_path.write_text(" .\n") + source_path.write_text(" .\n") + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=tasks_tmp_dir, + name="test_pipeline") + + pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) + + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) + + +@pytest.mark.parametrize("config_idx", range(len(load_rdf_sampled_pipeline_configs()))) +def test_rdf_pipeline_from_saved_sampled_configs(config_idx): + """Runs KGpipe using PipelineConfigs materialized from the JSON fixture written by test_pipeline_config.""" + configs = load_rdf_sampled_pipeline_configs() + assert configs, "fixtures/rdf_sampled_pipeline_configs.json is missing or empty; run test_enumerate_all_valid_rdf_task_combinations_with_config_sampling" + + pipeline_config = configs[config_idx] + + seed_path = tmp_base_dir / "seed_saved_sample.nt" + source_path = tmp_base_dir / "source_saved_sample.nt" + result_path = tmp_base_dir / f"result_saved_sample_config_idx_{config_idx}.nt" + tasks_tmp_dir = tmp_base_dir / f"tasks_tmp_saved_sample_config_idx_{config_idx}" + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) + + seed_path.write_text(" .\n") + source_path.write_text(" .\n") + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=tasks_tmp_dir, + name="test_pipeline_saved_sample", + ) + + pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), + ) + + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) + + + +def get_default_text_pipeline_config() -> PipelineConfig: + return PipelineConfig( + tasks=[ + corenlp_text_extraction_task, + entity_linker_label_alias_embedding_transformer_task, + relation_linker_label_alias_embedding_transformer_task, + aggregate_text_tasks_task, + generate_rdf_from_text_results_task, + select_first_value_task, + ], + config_catalog={ + "entity_linker_label_alias_embedding_transformer": ConfigurationProfile( + name="entity_linker_label_alias_embedding_transformer", + definition=entity_linker_label_alias_embedding_transformer_task.config_spec, + bindings=[ + ParameterBinding(parameter=_get_param(entity_linker_label_alias_embedding_transformer_task.config_spec, "model_name"), value="sentence-transformers/all-MiniLM-L6-v2"), + ParameterBinding(parameter=_get_param(entity_linker_label_alias_embedding_transformer_task.config_spec, "similarity_threshold"), value=0.5), + ], + ), + "relation_linker_label_alias_embedding_transformer": ConfigurationProfile( + name="relation_linker_label_alias_embedding_transformer", + definition=relation_linker_label_alias_embedding_transformer_task.config_spec, + bindings=[ + ParameterBinding(parameter=_get_param(relation_linker_label_alias_embedding_transformer_task.config_spec, "model_name"), value="sentence-transformers/all-MiniLM-L6-v2"), + ParameterBinding(parameter=_get_param(relation_linker_label_alias_embedding_transformer_task.config_spec, "similarity_threshold"), value=0.5), + ], + ), + }, + ) + +def test_text_pipeline_from_default_config(): + pipeline_config = get_default_text_pipeline_config() + + import os + os.environ["ONTOLOGY_PATH"] = "data/input_final/target_kg/ontology.ttl" + + seed_path = Path("data/input_final/target_kg/graph.nt") + source_path = Path("data/input_final/txt_source/docs") + result_path = Path("data/tmp/text_pipelines/result.nt") + tasks_tmp_dir = Path("data/tmp/text_pipelines/tasks_tmp") + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=tasks_tmp_dir, + name="test_text_pipeline") + + pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=source_path, format=DataFormat.TEXT), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) + + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=False) + + diff --git a/experiments/param-opti/src/qap/test_pipeline_config.py b/experiments/param-opti/src/qap/test_pipeline_config.py index e1e7c79..ee9c14f 100644 --- a/experiments/param-opti/src/qap/test_pipeline_config.py +++ b/experiments/param-opti/src/qap/test_pipeline_config.py @@ -1,12 +1,24 @@ from typing import List, Dict, Any, Optional +import json import random from kgpipe.common import KgPipe, Data, DataFormat, Registry from kgpipe.common.model.configuration import ConfigurationProfile, ParameterBinding from kgpipe.common.model.task import KgTask from pydantic import BaseModel -from param_opti.tasks.paris import paris_graph_alignment_task + +from param_opti.tasks.paris import paris_graph_alignment_task, paris_entity_alignment_task, paris_ontology_matching_task from param_opti.tasks.fusion import fusion_first_value_task from param_opti.tasks.base_linker import relation_linker_label_alias_embedding_transformer_task, entity_linker_label_alias_embedding_transformer_task +from param_opti.tasks.base_matcher import ( + graph_alignment_label_alias_embedding_transformer_task, + relation_matcher_label_alias_embedding_transformer_task, + entity_matcher_label_alias_embedding_transformer_task, +) +from param_opti.tasks.corenlp import corenlp_text_extraction_task +from param_opti.tasks.genie import genie_text_extraction_task +from param_opti.tasks.spotlight import spotlight_entity_linking_task +from param_opti.tasks.matching_helpers import aggregate_matching_results_task + from kgpipe.generation.loaders import build_from_conf from pathlib import Path # for given tasks and config parameters, generate a pipeline (KGpipe) @@ -15,23 +27,52 @@ if not tmp_base_dir.exists(): tmp_base_dir.mkdir(parents=True, exist_ok=True) +RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "rdf_sampled_pipeline_configs.json" +_RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 + class PipelineConfig(BaseModel): tasks: List[KgTask] config_catalog: Dict[str, ConfigurationProfile] -SEARCH_SPACE = { +RDF_SEARCH_SPACE = { + "graph_alignment_label_alias_embedding_transformer_task": { + "category": ["ontology_matching", "entity_matching", "aggregate_matching_results"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "relation_matcher_label_alias_embedding_transformer_task": { + "category": ["ontology_matching"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "entity_matcher_label_alias_embedding_transformer_task": { + "category": ["entity_matching"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "paris_ontology_matching_task": { + "category": ["ontology_matching"], + "ontology_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "paris_entity_alignment_task": { + "category": ["entity_matching"], + "entity_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, "paris_graph_alignment_task": { - "category": "entity_matching", + "category": ["ontology_matching", "entity_matching", "aggregate_matching_results"], "entity_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], "relation_matching_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, + "aggregate_matching_results_task": { + "category": ["aggregate_matching_results"], + }, "fusion_first_value_task": { - "category": "fusion", + "category": ["fusion"], # "fusion_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, "relation_linker_label_alias_embedding_transformer_task": { - "category": "entity_linking", + "category": ["entity_linking"], "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, @@ -42,14 +83,60 @@ class PipelineConfig(BaseModel): }, } -task_dict = { +TEXT_SEARCH_SPACE = { + "corenlp_text_extraction_task": { + "category": ["information_extraction"], + # does not have config parameters + }, + "genie_text_extraction_task": { + "category": ["information_extraction"], + # does not have config parameters + }, + "spotlight_entity_linking_task": { + "category": ["entity_linking"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "relation_linker_label_alias_embedding_transformer_task": { + "category": ["relation_linking"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "entity_linker_label_alias_embedding_transformer_task": { + "category": ["entity_linking"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], + }, + "fusion_first_value_task": { + "category": ["fusion"], + }, +} + +TEXT_TASK_DICT = { + "corenlp_text_extraction_task": corenlp_text_extraction_task, + "genie_text_extraction_task": genie_text_extraction_task, + "spotlight_entity_linking_task": spotlight_entity_linking_task, + "relation_linker_label_alias_embedding_transformer_task": relation_linker_label_alias_embedding_transformer_task, + "entity_linker_label_alias_embedding_transformer_task": entity_linker_label_alias_embedding_transformer_task, + "fusion_first_value_task": fusion_first_value_task, +} + +RDF_TASK_DICT = { + "graph_alignment_label_alias_embedding_transformer_task": graph_alignment_label_alias_embedding_transformer_task, + "relation_matcher_label_alias_embedding_transformer_task": relation_matcher_label_alias_embedding_transformer_task, + "entity_matcher_label_alias_embedding_transformer_task": entity_matcher_label_alias_embedding_transformer_task, + "paris_ontology_matching_task": paris_ontology_matching_task, + "paris_entity_alignment_task": paris_entity_alignment_task, "paris_graph_alignment_task": paris_graph_alignment_task, "fusion_first_value_task": fusion_first_value_task, "relation_linker_label_alias_embedding_transformer_task": relation_linker_label_alias_embedding_transformer_task, "entity_linker_label_alias_embedding_transformer_task": entity_linker_label_alias_embedding_transformer_task, + "aggregate_matching_results_task": aggregate_matching_results_task, + # "fusion_union_task": fusion_union_task, } -for task_name, task in task_dict.items(): +task_dict = {**TEXT_TASK_DICT, **RDF_TASK_DICT} + +for task_name, task in RDF_TASK_DICT.items(): Registry.add_task(task_name, task) class PipelineLayout(BaseModel): @@ -58,6 +145,72 @@ class PipelineLayout(BaseModel): """ allowed_task_categories: List[str] + +def _task_categories_list(search_space: Dict[str, Dict[str, Any]], task_name: str) -> List[str]: + raw = search_space.get(task_name, {}).get("category") + if isinstance(raw, list): + return [c for c in raw if isinstance(c, str)] + if isinstance(raw, str): + return [raw] + return [] + + +def enumerate_valid_task_combinations( + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, +) -> List[List[str]]: + """ + Enumerate all possible task-name combinations for the given pipeline layout, + respecting category order and multi-category coverage, without sampling config options. + + A task is only eligible for the current category if its declared categories are + disjoint from categories already covered by earlier tasks. That avoids pairing e.g. + Paris ontology matching with a dual-category embedding matcher that would repeat + ontology coverage when only entity matching is still needed. + """ + all_task_names = list(search_space.keys()) + + combos: List[List[str]] = [[]] + covered_sets: List[set[str]] = [set()] + + for category in pipeline_layout.allowed_task_categories: + next_combos: List[List[str]] = [] + next_covered_sets: List[set[str]] = [] + + for combo, covered in zip(combos, covered_sets): + if category in covered: + next_combos.append(combo) + next_covered_sets.append(covered) + continue + + eligible: List[str] = [] + for tn in all_task_names: + cats = _task_categories_list(search_space, tn) + if category not in cats: + continue + if set(cats) & covered: + continue + eligible.append(tn) + for tn in eligible: + new_combo = combo + [tn] + new_covered = set(covered) + new_covered.update(_task_categories_list(search_space, tn)) + next_combos.append(new_combo) + next_covered_sets.append(new_covered) + + combos, covered_sets = next_combos, next_covered_sets + + # De-duplicate while keeping stable order. + seen: set[tuple[str, ...]] = set() + unique: List[List[str]] = [] + for c in combos: + t = tuple(c) + if t in seen: + continue + seen.add(t) + unique.append(c) + return unique + def _get_param(definition: Any, param_name: str): params = getattr(definition, "parameters", None) if params is None: @@ -75,24 +228,63 @@ def _get_param(definition: Any, param_name: str): return p raise KeyError(f"Parameter {param_name} not found in config_spec.parameters") -def get_default_rdf_pipeline_config() -> PipelineConfig: - return PipelineConfig( - tasks=[ - paris_graph_alignment_task, - fusion_first_value_task, - ], - config_catalog={ - # Key must match KgTask.name because KgPipe delegates by task.name - "paris_graph_alignment": ConfigurationProfile( - name="paris_graph_alignment", - definition=paris_graph_alignment_task.config_spec, - bindings=[ - ParameterBinding(parameter=_get_param(paris_graph_alignment_task.config_spec, "entity_matching_threshold"), value=0.5), - ParameterBinding(parameter=_get_param(paris_graph_alignment_task.config_spec, "relation_matching_threshold"), value=0.5), - ], + +def pipeline_config_to_snapshot(task_keys: List[str], pipeline_config: PipelineConfig) -> Dict[str, Any]: + profiles: Dict[str, Any] = {} + for task in pipeline_config.tasks: + prof = pipeline_config.config_catalog.get(task.name) + if prof is None: + continue + profiles[task.name] = { + "profile_name": prof.name, + "bindings": [ + {"parameter": binding.parameter.name, "value": binding.value} + for binding in prof.bindings + ], + } + return {"task_keys": task_keys, "profiles": profiles} + + +def pipeline_config_from_snapshot(snapshot: Dict[str, Any]) -> PipelineConfig: + task_keys: List[str] = snapshot["task_keys"] + profiles: Dict[str, Any] = snapshot.get("profiles") or {} + tasks: List[KgTask] = [] + config_catalog: Dict[str, ConfigurationProfile] = {} + + for task_key in task_keys: + task = task_dict[task_key] + tasks.append(task) + prof_data = profiles.get(task.name) + if prof_data is None: + continue + if getattr(task, "config_spec", None) is None: + continue + bindings = [ + ParameterBinding( + parameter=_get_param(task.config_spec, b["parameter"]), + value=b["value"], ) - }, - ) + for b in prof_data["bindings"] + ] + config_catalog[task.name] = ConfigurationProfile( + name=prof_data["profile_name"], + definition=task.config_spec, + bindings=bindings, + ) + + return PipelineConfig(tasks=tasks, config_catalog=config_catalog) + + +def load_rdf_sampled_pipeline_configs(path: Optional[Path] = None) -> List[PipelineConfig]: + fixture_path = path or RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE + raw = json.loads(fixture_path.read_text(encoding="utf-8")) + if raw.get("version") != _RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION: + raise ValueError( + f"Unsupported rdf sampled configs snapshot version {raw.get('version')!r}; " + f"expected {_RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION}" + ) + return [pipeline_config_from_snapshot(item) for item in raw["samples"]] + # TODO rules for valid pipeline config: def sample_valid_pipeline_config( @@ -105,16 +297,40 @@ def sample_valid_pipeline_config( """ tasks: List[KgTask] = [] config_catalog: Dict[str, ConfigurationProfile] = {} + covered_categories: set[str] = set() for category in pipeline_layout.allowed_task_categories: + if category in covered_categories: + continue + eligible_task_names = [ - tn for tn, space in search_space.items() if space.get("category") == category + tn + for tn, space in search_space.items() + if ( + space.get("category") == category + or ( + isinstance(space.get("category"), list) + and category in (space.get("category") or []) + ) + ) ] if not eligible_task_names: continue + eligible_task_names = [ + tn + for tn in eligible_task_names + if not (set(_task_categories_list(search_space, tn)) & covered_categories) + ] + if not eligible_task_names: + raise ValueError( + f"No task can cover category {category!r} without overlapping already covered " + f"categories {sorted(covered_categories)}. Adjust search_space or pipeline_layout." + ) + task_key = random.choice(eligible_task_names) task = task_dict[task_key] + covered_categories.update(_task_categories_list(search_space, task_key)) tasks.append(task) # metadata only or task has no config spec @@ -170,63 +386,186 @@ def print_pipeline_config_short(pipeline_config: PipelineConfig): params = ", ".join(parts) print(f"- {task_name}({params})") +def sample_config_catalog_for_task_combo( + search_space: Dict[str, Dict[str, Any]], + task_name_combo: List[str], + *, + rng: random.Random, +) -> PipelineConfig: + tasks: List[KgTask] = [] + config_catalog: Dict[str, ConfigurationProfile] = {} + + for task_key in task_name_combo: + task = task_dict[task_key] + tasks.append(task) + + if getattr(task, "config_spec", None) is None: + continue + + bindings: List[ParameterBinding] = [] + name_parts: List[str] = [] + for config_name, config_values in search_space[task_key].items(): + if config_name == "category": + continue + if not isinstance(config_values, list): + raise TypeError( + f"Search space values must be lists; got {task_key}.{config_name}={type(config_values)}" + ) + if not config_values: + raise ValueError(f"Empty search space for {task_key}.{config_name}") + + config_value = rng.choice(config_values) + name_parts.append(f"{config_name}={config_value}") + bindings.append( + ParameterBinding( + parameter=_get_param(task.config_spec, config_name), + value=config_value, + ) + ) + + if bindings: + config_catalog[task.name] = ConfigurationProfile( + name=f"{task.name}_" + ",".join(name_parts), + definition=task.config_spec, + bindings=bindings, + ) + + return PipelineConfig(tasks=tasks, config_catalog=config_catalog) + def test_sample_valid_rdf_pipeline_config(): pipeline_layout = PipelineLayout( - allowed_task_categories=["ontology_matching", "entity_matching", "fusion"] + allowed_task_categories=["ontology_matching", "entity_matching", "aggregate_matching_results", "fusion"] ) - pipeline_config = sample_valid_pipeline_config(SEARCH_SPACE, pipeline_layout) + pipeline_config = sample_valid_pipeline_config(RDF_SEARCH_SPACE, pipeline_layout) print_pipeline_config_short(pipeline_config) +def test_enumerate_all_valid_rdf_task_combinations_no_config_sampling(): + print("enumerate_all_valid_rdf_task_combinations_no_config_sampling") + pipeline_layout = PipelineLayout( + allowed_task_categories=["ontology_matching", "entity_matching", "aggregate_matching_results", "fusion"] + ) + combos = enumerate_valid_task_combinations(RDF_SEARCH_SPACE, pipeline_layout) + + for combo in combos: + print(combo) + + # With current SEARCH_SPACE: + # - ontology_matching can be satisfied by paris_ontology_matching_task, paris_entity_alignment_task, paris_graph_alignment_task + # - entity_matching can be satisfied by paris_entity_alignment_task, paris_graph_alignment_task (and may be skipped if already covered) + # - fusion must be satisfied by fusion_first_value_task + # expected = { + # ("paris_ontology_matching_task", "paris_entity_alignment_task", "fusion_first_value_task"), + # ("paris_ontology_matching_task", "paris_graph_alignment_task", "fusion_first_value_task"), + # ("paris_graph_alignment_task", "fusion_first_value_task"), + # } + + # assert set(tuple(c) for c in combos) == expected + +def test_enumerate_all_valid_rdf_task_combinations_with_config_sampling(): + print("enumerate_all_valid_rdf_task_combinations_with_config_sampling") + n = 1 + rng = random.Random(0) + + pipeline_layout = PipelineLayout( + allowed_task_categories=["ontology_matching", "entity_matching", "aggregate_matching_results", "fusion"] + ) + combos = enumerate_valid_task_combinations(RDF_SEARCH_SPACE, pipeline_layout) + + total_config_count = 0 + snapshots: List[Dict[str, Any]] = [] + + for combo in combos: + print() + print("combo:", combo) + for i in range(n): + total_config_count += 1 + print(f"sample {total_config_count}/{len(combos) * n}") + pipeline_config = sample_config_catalog_for_task_combo( + RDF_SEARCH_SPACE, combo, rng=rng + ) + + print_pipeline_config_short(pipeline_config) + snapshots.append(pipeline_config_to_snapshot(combo, pipeline_config)) + + RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE.parent.mkdir(parents=True, exist_ok=True) + RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE.write_text( + json.dumps( + {"version": _RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION, "samples": snapshots}, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + + def test_sample_valid_text_pipeline_config(): pipeline_layout = PipelineLayout( - allowed_task_categories=["information_extraction", "entity_linking", "fusion"] + allowed_task_categories=["information_extraction", "entity_linking", "relation_linking", "fusion"] ) - pipeline_config = sample_valid_pipeline_config(SEARCH_SPACE, pipeline_layout) + pipeline_config = sample_valid_pipeline_config(TEXT_SEARCH_SPACE, pipeline_layout) print_pipeline_config_short(pipeline_config) -def test_rdf_pipeline_from_default_config(): - pipeline_config = get_default_rdf_pipeline_config() - - seed_path = tmp_base_dir / "seed.nt" - source_path = tmp_base_dir / "source.nt" - result_path = tmp_base_dir / "result.nt" +def test_enumerate_all_valid_text_task_combinations_no_config_sampling(): + print("enumerate_all_valid_text_task_combinations_no_config_sampling") + pipeline_layout = PipelineLayout( + allowed_task_categories=["information_extraction", "entity_linking", "relation_linking", "fusion"] + ) + combos = enumerate_valid_task_combinations(TEXT_SEARCH_SPACE, pipeline_layout) + for combo in combos: + print(combo) +def test_enumerate_all_valid_text_task_combinations_with_config_sampling(): + print("enumerate_all_valid_text_task_combinations_with_config_sampling") + n = 5 + rng = random.Random(0) - pipeline = KgPipe( - tasks=pipeline_config.tasks, - seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), - data_dir=tmp_base_dir / "tasks_tmp", - name="test_pipeline") + pipeline_layout = PipelineLayout( + allowed_task_categories=["information_extraction", "entity_linking", "relation_linking", "fusion"] + ) + combos = enumerate_valid_task_combinations(TEXT_SEARCH_SPACE, pipeline_layout) + + total_config_count = 0 + + for combo in combos: + print() + print("combo:", combo) + for i in range(n): + total_config_count += 1 + print(f"sample {total_config_count}/{len(combos) * n}") + pipeline_config = sample_config_catalog_for_task_combo( + TEXT_SEARCH_SPACE, combo, rng=rng + ) + print_pipeline_config_short(pipeline_config) - pipeline.build( - stable_files=True, - configCatalog=pipeline_config.config_catalog, - source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), - result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) - pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) -def test_rdf_pipeline_from_config(): - pipeline_config = sample_valid_pipeline_config(SEARCH_SPACE, PipelineLayout(allowed_task_categories=["entity_matching", "fusion"])) +# def test_rdf_pipeline_from_config(): +# pipeline_config = sample_valid_pipeline_config(RDF_SEARCH_SPACE, PipelineLayout(allowed_task_categories=["entity_matching", "fusion"])) - seed_path = tmp_base_dir / "seed.nt" - source_path = tmp_base_dir / "source.nt" - result_path = tmp_base_dir / "result.nt" +# seed_path = tmp_base_dir / "seed.nt" +# source_path = tmp_base_dir / "source.nt" +# result_path = tmp_base_dir / "result.nt" +# tasks_tmp_dir = tmp_base_dir / "tasks_tmp" +# tasks_tmp_dir.mkdir(parents=True, exist_ok=True) +# # Ensure inputs exist for pipeline execution. +# seed_path.write_text(" .\n") +# source_path.write_text(" .\n") - pipeline = KgPipe( - tasks=pipeline_config.tasks, - seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), - data_dir=tmp_base_dir / "tasks_tmp", - name="test_pipeline") +# pipeline = KgPipe( +# tasks=pipeline_config.tasks, +# seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), +# data_dir=tasks_tmp_dir, +# name="test_pipeline") - pipeline.build( - stable_files=True, - configCatalog=pipeline_config.config_catalog, - source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), - result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) +# pipeline.build( +# stable_files=True, +# configCatalog=pipeline_config.config_catalog, +# source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), +# result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES)) - pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) \ No newline at end of file +# pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) \ No newline at end of file diff --git a/experiments/param-opti/src/qap/test_ref_based.py b/experiments/param-opti/src/qap/test_ref_based.py index b4fb3c3..fca309f 100644 --- a/experiments/param-opti/src/qap/test_ref_based.py +++ b/experiments/param-opti/src/qap/test_ref_based.py @@ -1,8 +1,12 @@ from kgpipe.common import KgPipe, Data, DataFormat -from param_opti.tasks.paris import paris_entity_alignment_task, paris_graph_alignment_task -from param_opti.tasks.fusion import fusion_first_value_task, fusion_union_task +from kgpipe.common.model.configuration import ConfigurationProfile, ParameterBinding, ConfigurationDefinition +from param_opti.tasks.paris import paris_graph_alignment_task +from param_opti.tasks.fusion import fusion_first_value_task +from param_opti.tasks.openie import openie_pipeline_task +from param_opti.tasks.base_linker import relation_linker_label_alias_embedding_transformer_task, entity_linker_label_alias_embedding_transformer_task from pathlib import Path - +from typing import List +import pytest # Using ground truth # 1. execute PARIS pipeline, with different thresholds @@ -11,18 +15,191 @@ # - [ ] impl paris wrapper with exchange and threshold filter -seed_path = Path("data/seed.nt") -pipe_result_dir_path = Path("data/pipe_result") +ontology_path = "tmp/ontology.ttl" + +tmp_base_dir = Path("data/tmp/rdf_pipelines") +tmp_base_dir.mkdir(parents=True, exist_ok=True) + + +def _write_to_file(string: str, path: Path): + with open(path, "w") as f: + f.write(string) + +def _get_param(definition: ConfigurationDefinition, param_name: str): + params = getattr(definition, "parameters", None) + if params is None: + raise KeyError(f"Task config_spec has no parameters field (missing {param_name})") + + if hasattr(params, "get"): + p = params.get(param_name) + if p is None: + raise KeyError(f"Parameter {param_name} not found in config_spec.parameters") + return p + + for p in params: + if getattr(p, "name", None) == param_name: + return p + raise KeyError(f"Parameter {param_name} not found in config_spec.parameters") + + +def get_paris_pipeline(entity_matching_threshold: float, relation_matching_threshold: float): + name = ( + f"paris_graph_alignment(entity={entity_matching_threshold},rel={relation_matching_threshold})" + "_fusion_first_value" + ) -def get_paris_pipeline(threshold: float): - name = f"paris_graph_alignment_task={threshold}_fusion_first_value_task" + seed_path = Path("data/inputs/target_kg/data.nt") + source_path = Path("data/inputs/rdf_source/data.nt") + result_path = Path(f"data/tmp/rdf_pipelines/result_{entity_matching_threshold}_{relation_matching_threshold}.nt") + tasks_tmp_dir = Path(f"data/tmp/rdf_pipelines/tasks_tmp_{entity_matching_threshold}_{relation_matching_threshold}") + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) - return KgPipe( + config_catalog = { + "paris_graph_alignment": ConfigurationProfile( + name=f"paris_graph_alignment_entity={entity_matching_threshold},relation={relation_matching_threshold}", + definition=paris_graph_alignment_task.config_spec, + bindings=[ + ParameterBinding( + parameter=_get_param(paris_graph_alignment_task.config_spec, "entity_matching_threshold"), + value=entity_matching_threshold, + ), + ParameterBinding( + parameter=_get_param(paris_graph_alignment_task.config_spec, "relation_matching_threshold"), + value=relation_matching_threshold, + ), + ], + ), + "fusion_first_value": ConfigurationProfile( + name="fusion_first_value", + definition=fusion_first_value_task.config_spec, + bindings=[ + ParameterBinding( + parameter=_get_param(fusion_first_value_task.config_spec, "ontology_path"), + value=ontology_path, + ), + ], + ) + } + + pipeline = KgPipe( name=name, tasks=[paris_graph_alignment_task, fusion_first_value_task], seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), - data_dir=pipe_result_dir_path / "tmp" + data_dir=tasks_tmp_dir, + ) + + pipeline.build( + stable_files=True, + configCatalog=config_catalog, + source=Data(path=source_path, format=DataFormat.RDF_NTRIPLES), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), ) -def test_paris_pipelines(): - pass \ No newline at end of file + return pipeline, config_catalog + +def get_openie_pipeline(entity_linking_threshold: float, relation_linking_threshold: float): + name = ( + f"openie_pipeline(entity={entity_linking_threshold},rel={relation_linking_threshold})" + ) + + seed_path = Path("data/inputs/target_kg/data.nt") + source_path = Path("data/inputs/text_source/docs") + result_path = Path(f"data/tmp/rdf_pipelines/result_{entity_linking_threshold}_{relation_linking_threshold}.nt") + tasks_tmp_dir = Path(f"data/tmp/rdf_pipelines/tasks_tmp_{entity_linking_threshold}_{relation_linking_threshold}") + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) + + config_catalog = { + "openie_pipeline": ConfigurationProfile( + name=name, + definition=openie_pipeline_task.config_spec, + bindings=[ + ParameterBinding(parameter=_get_param(openie_pipeline_task.config_spec, "entity_linking_threshold"), value=entity_linking_threshold), + ParameterBinding(parameter=_get_param(openie_pipeline_task.config_spec, "relation_linking_threshold"), value=relation_linking_threshold), + ], + ), + } + + pipeline = KgPipe( + name=name, + tasks=[openie_pipeline_task, relation_linker_label_alias_embedding_transformer_task, entity_linker_label_alias_embedding_transformer_task], + seed=Data(path=seed_path, format=DataFormat.TEXT), + data_dir=tasks_tmp_dir, + ) + + pipeline.build( + stable_files=True, + configCatalog=config_catalog, + source=Data(path=source_path, format=DataFormat.TEXT), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), + ) + + return pipeline, config_catalog + +# parameterize the test with different thresholds for entity matching and relation matching +@pytest.mark.parametrize("entity_matching_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) +@pytest.mark.parametrize("relation_matching_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) +def test_paris_pipelines(entity_matching_threshold, relation_matching_threshold): + """ + test a paris pipeline with different thresholds for entity matching and relation matching + """ + pipeline, config_catalog = get_paris_pipeline( + entity_matching_threshold, relation_matching_threshold + ) + pipeline.run(configCatalog=config_catalog, stable_files_override=False) + print(f"Pipeline run with entity_matching_threshold={entity_matching_threshold} and relation_matching_threshold={relation_matching_threshold}") + + +@pytest.mark.parametrize("entity_matching_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) +@pytest.mark.parametrize("relation_matching_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) +def test_eval_paris_pipeline(entity_matching_threshold, relation_matching_threshold): + """ + evaluate a paris pipeline with different thresholds for entity matching and relation matching + current best "entity_alignment_0.9_0.7" with f1 score 0.971 + """ + + print(f"Evaluating triple alignment with entity_matching_threshold={entity_matching_threshold} and relation_matching_threshold={relation_matching_threshold}...") + from kgpipe_eval.utils.kg_utils import KgManager + from kgpipe_eval.metrics.triple_alignment import TripleAlignmentMetric, TripleAlignmentConfig + from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric, EntityAlignmentConfig + from kgpipe_eval.api import MetricResult + from kgpipe_eval.test.utils import render_metric_result + + ref_kg_path = Path("data/inputs/reference_kg/data_agg.nt") + gen_kg_path = Path(f"data/tmp/rdf_pipelines/result_{entity_matching_threshold}_{relation_matching_threshold}.nt") + + entity_alignment_config = EntityAlignmentConfig( + method="label_embedding", + reference_kg=ref_kg_path, + verified_entities_path=None, + verified_entities_delimiter="\t", + entity_sim_threshold=0.95 + ) + + tg = KgManager.load_kg(gen_kg_path) + metric_result : MetricResult = EntityAlignmentMetric().compute(tg, entity_alignment_config) + result_string = render_metric_result(metric_result) + _write_to_file(result_string, Path(f"data/tmp/rdf_pipelines/entity_alignment_{entity_matching_threshold}_{relation_matching_threshold}.txt")) + + + triple_alignment_config = TripleAlignmentConfig( + reference_kg=ref_kg_path, + entity_alignment_config=entity_alignment_config, + value_sim_threshold=0.5, + cache_literal_embeddings=True + ) + + tg = KgManager.load_kg(gen_kg_path) + metric_result : MetricResult = TripleAlignmentMetric().compute(tg, triple_alignment_config) + result_string = render_metric_result(metric_result) + _write_to_file(result_string, Path(f"data/tmp/rdf_pipelines/triple_alignment_{entity_matching_threshold}_{relation_matching_threshold}.txt")) + + +@pytest.mark.parametrize("entity_linking_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) +@pytest.mark.parametrize("relation_linking_threshold", [0.5, 0.6, 0.7, 0.8, 0.9]) +def test_openie_pipeline(entity_linking_threshold, relation_linking_threshold): + """ + test the openie pipeline + """ + pipeline, config_catalog = get_openie_pipeline(entity_linking_threshold, relation_linking_threshold) + + print(pipeline.plan()) \ No newline at end of file diff --git a/experiments/param-opti/src/qap/test_sge_based.py b/experiments/param-opti/src/qap/test_sge_based.py new file mode 100644 index 0000000..b6674bd --- /dev/null +++ b/experiments/param-opti/src/qap/test_sge_based.py @@ -0,0 +1,36 @@ +def eval_paris_pipeline(entity_matching_threshold: float, relation_matching_threshold: float): + """ + evaluate a paris pipeline with different thresholds for entity matching and relation matching + """ + pass + + # ref_kg_path = Path("data/inputs/reference_kg/data_agg.nt") + # gen_kg_path = Path(f"data/tmp/rdf_pipelines/result_{entity_matching_threshold}_{relation_matching_threshold}.nt") + + # source_grounded_correctness_config = SourceGroundedCorrectnessConfig( + # kg_graph=ref_kg_path, + # source_corpus=gen_kg_path, + # index_dir=Path("data/tmp/source_grounded_correctness"), + # verbalize_method="natural", + # verifier="nli", + # nli_model="facebook/bart-large-mnli", + # nli_device="cpu", + # llm_model="gpt-4.1-mini", + # llm_device="cpu" + # ) + + # source_grounded_correctness_metric = SourceGroundedCorrectnessMetric() + # source_grounded_correctness_metric.compute(KgManager.load_kg(gen_kg_path), source_grounded_correctness_config) + +def eval_openie_pipeline(): + """ + evaluate an openie pipeline + """ + pass + + # ref_kg_path = Path("data/inputs/reference_kg/data_agg.nt") + # gen_kg_path = Path(f"data/tmp/rdf_pipelines/result_{entity_matching_threshold}_{relation_matching_threshold}.nt") + + # source_grounded_coverage_config = SourceGroundedCoverageConfig( + # kg_graph=ref_kg_path, + # source_corpus=gen_kg_path, \ No newline at end of file diff --git a/src/kgpipe/common/model/configuration.py b/src/kgpipe/common/model/configuration.py index 4ebacb9..0c52bec 100644 --- a/src/kgpipe/common/model/configuration.py +++ b/src/kgpipe/common/model/configuration.py @@ -33,13 +33,13 @@ class Parameter(BaseModel): # +allowed_values: any[*]? # +min/max/unit: number?/number?/string? name: str - native_keys: List[str] datatype: ParameterType - default_value: str | int | float | bool - required: bool + default_value: str | int | float | bool = field(default_factory=lambda: None) + required: bool = False + native_keys: List[str] = field(default_factory=list) # scope: Scope # (training/inference/io/resources) # constraints - allowed_values: List[str | int | float | bool] + allowed_values: List[str | int | float | bool] = field(default_factory=list) minimum: Optional[float] = None maximum: Optional[float] = None unit: Optional[str] = None diff --git a/src/kgpipe_eval/metrics/triple_alignment.py b/src/kgpipe_eval/metrics/triple_alignment.py index 7934ff6..516f18d 100644 --- a/src/kgpipe_eval/metrics/triple_alignment.py +++ b/src/kgpipe_eval/metrics/triple_alignment.py @@ -3,29 +3,71 @@ from kgpipe.common import KG from kgpipe_eval.metrics.entity_alignment import EntityAlignmentConfig +from kgpipe_eval.utils.kg_utils import KgLike, KgManager, TripleGraph +from kgpipe_eval.utils.alignment_utils import align_triples_by_value_embedding from kgpipe_eval.utils.measurement_utils import BCMeasurement -from kgpipe_eval.api import Metric +from kgpipe_eval.api import Measurement, Metric, MetricResult # measures precision, recall, f1 score, etc. class TripleAlignmentConfig(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - reference_kg: KG + reference_kg: KgLike + method: Literal["value_embedding", "exact"] = "value_embedding" entity_alignment_config: EntityAlignmentConfig value_sim_threshold: float = 0.5 + cache_literal_embeddings: bool = False + cache_ref_literal_embeddings: bool = True -# def eval_triple_alignment(method: Literal["exact", "fuzzy", "semantic"] = "exact"): -# pass +def eval_triple_alignment(tg: TripleGraph, config: TripleAlignmentConfig): + if config.method == "value_embedding": + alignments = align_triples_by_value_embedding(tg, config) + elif config.method == "exact": + pass + # alignments = align_triples_by_exact_match(tg, config) + else: + raise ValueError(f"Invalid method: {config.method}") + + print("Triple alignments: ", len(alignments)) + + ref_tg = KgManager.load_kg(config.reference_kg) + ref_triples = set(ref_tg.triples((None, None, None))) + gen_triples = set(tg.triples((None, None, None))) + + aligned_ref_triples = set(a.target for a in alignments) + aligned_gen_triples = set(a.source for a in alignments) -def eval_triple_alignment_by_label_embedding(method: Literal["exact", "fuzzy", "semantic"] = "exact"): - pass + tp = len(aligned_ref_triples) # aligned reference triples + fp = len(gen_triples - aligned_gen_triples) # generated triples not aligned to any reference triple + tn = 0 + fn = len(ref_triples - aligned_ref_triples) # reference triples missing in generation + return BCMeasurement(tp=tp, fp=fp, tn=tn, fn=fn) -def eval_triple_alignment_by_label_embedding_soft_literals(method: Literal["exact", "fuzzy", "semantic"] = "exact"): - pass +# def eval_triple_alignment_by_label_embedding(method: Literal["exact", "fuzzy", "semantic"] = "exact"): +# pass + + +# def eval_triple_alignment_by_label_embedding_soft_literals(method: Literal["exact", "fuzzy", "semantic"] = "exact"): +# pass class ReferenceTripleAlignmentMetric(Metric): - pass + + def compute(self, kg: KG, config: TripleAlignmentConfig): + m: BCMeasurement = eval_triple_alignment(kg, config) + return MetricResult( + metric=self, + measurements=[ + Measurement(name="tp", value=m.tp, unit="number"), + Measurement(name="fp", value=m.fp, unit="number"), + Measurement(name="tn", value=m.tn, unit="number"), + Measurement(name="fn", value=m.fn, unit="number"), + Measurement(name="precision", value=m.precision(), unit="percentage"), + Measurement(name="recall", value=m.recall(), unit="percentage"), + Measurement(name="f1_score", value=m.f1_score(), unit="percentage"), + ], + summary=f"Triple alignment by {config.method}", + ) # Backward-compatibility alias (imported by `kgpipe_eval.metrics.__init__`). diff --git a/src/kgpipe_eval/test/examples.py b/src/kgpipe_eval/test/examples.py index dc7aeb3..06ad14d 100644 --- a/src/kgpipe_eval/test/examples.py +++ b/src/kgpipe_eval/test/examples.py @@ -14,7 +14,6 @@ rdfs:label "HarperCollins" ; :countryCode "GB" . """ - TEST_TURTLE_TRIPLES = """ @prefix : . @prefix o: . @@ -96,8 +95,89 @@ rdfs:label "Unexpected Entity"@en . """ +GENERATED_TURTLE_TRIPLES = """ +@prefix : . +@prefix o: . +@prefix rdf: . +@prefix rdfs: . +@prefix xsd: . + +# Entities designed to exercise alignment corner-cases: +# - multiple entities per type (Book/Author/Publisher/Store) +# - missing / extra attributes across graphs +# - literal variations (lang tags, datatypes, different lexical forms) +# - ambiguous labels (near-duplicates, casing differences) +# - multi-valued properties + +:store1 rdf:type o:BookStore ; + rdfs:label "Example Books (Downtown)"@en ; + :countryCode "US" ; + :hasInventory :itemA, :itemB, :itemC . + +:publisherHC rdf:type o:Publisher ; + rdfs:label "HarperCollins" ; + :countryCode "GB" . + +# different wrong type +:publisherPenguin rdf:type o:Author ; + rdfs:label "Penguin Books"@en ; + :countryCode "GB" . + +:authorTolkien rdf:type o:Author ; + rdfs:label "J. R. R. Tolkien" ; + :born "1892-01-03"^^xsd:date ; + :died "1973-09-02"^^xsd:date ; + :sameAs . + +:authorRowling rdf:type o:Author ; + rdfs:label "J.K. Rowling" ; + :born "1965-07-31"^^xsd:date . + +:itemA rdf:type o:Book ; + rdfs:label "The Hobbit"@en ; + :bookTitle "The Hobbit, or There and Back Again"@en ; + :bookAuthor :authorTolkien ; + :publisher :publisherHC ; + :isbn13 "9780261102217" ; + :pageCount "310"^^xsd:integer ; + :tags "fantasy", "classic" ; + :inSeries :seriesMiddleEarth . + +:itemB rdf:type o:Book ; + rdfs:label "The Hobbit (Illustrated)"@en ; + :bookTitle "The Hobbit"@en ; + :bookAuthor :authorTolkien ; + :publisher :publisherHC ; + :isbn13 "978-0-261-10221-7" ; # lexical variation + :pageCount 320 ; # integer without explicit datatype + :publicationYear "1997"^^xsd:gYear . + +:itemC rdf:type o:Book ; + rdfs:label "Harry Potter and the Philosopher's Stone"@en ; + :bookTitle "Harry Potter and the Philosopher's Stone"@en ; + :bookAuthor :authorRowling ; + :publisher :publisherPenguin ; + :isbn13 "9780747532699" ; + :pageCount "223"^^xsd:integer . + +# Same label, different type (common edge case for label-only alignment) +:hobbit rdf:type o:Film ; + rdfs:label "The Hobbit"@en ; + :releaseYear "2012"^^xsd:gYear . + +# Missing rdf:type but has label (edge case for type-aware matching) +:unknownEntity rdfs:label "HarperCollins" . + +:seriesMiddleEarth rdf:type o:Series ; + rdfs:label "Middle-earth Legendarium"@en . + +# false positive unexpected entity +:unexpectedEntity rdf:type o:Book ; + rdfs:label "Unexpected Entity"@en . +""" + REFERENCE_TURTLE_TRIPLES = """ -@prefix : . +@prefix : . @prefix o: . @prefix rdf: . @prefix rdfs: . diff --git a/src/kgpipe_eval/test/test_alignment_eval.py b/src/kgpipe_eval/test/test_alignment_eval.py index b8ddc9e..9d10a93 100644 --- a/src/kgpipe_eval/test/test_alignment_eval.py +++ b/src/kgpipe_eval/test/test_alignment_eval.py @@ -2,7 +2,8 @@ from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric -from kgpipe_eval.test.utils import get_test_kg, get_verified_entities_path, render_metric_result +from kgpipe_eval.metrics.triple_alignment import TripleAlignmentMetric, TripleAlignmentConfig +from kgpipe_eval.test.utils import get_test_kg, get_verified_entities_path, render_metric_result, get_reference_kg, get_generated_kg from kgpipe_eval.utils.kg_utils import KgManager from kgpipe_eval.api import MetricResult @@ -29,4 +30,33 @@ def test_align_entities_by_label_embedding_and_type(): ) tg = KgManager.load_kg(get_test_kg()) metric_result : MetricResult = EntityAlignmentMetric().compute(tg, config) - print(render_metric_result(metric_result)) \ No newline at end of file + print(render_metric_result(metric_result)) + +def test_align_entities_by_label_embedding_and_type_ref_kg(): + config = EntityAlignmentConfig( + method="label_embedding", + reference_kg=get_reference_kg(), + verified_entities_path=None, + verified_entities_delimiter="\t", + entity_sim_threshold=0.95 + ) + tg = KgManager.load_kg(get_test_kg()) + metric_result : MetricResult = EntityAlignmentMetric().compute(tg, config) + print(render_metric_result(metric_result)) + +def test_align_triples_by_value_embedding(): + config = TripleAlignmentConfig( + reference_kg=get_reference_kg(), + entity_alignment_config=EntityAlignmentConfig( + method="label_embedding", + reference_kg=get_reference_kg(), + verified_entities_path=None, + verified_entities_delimiter="\t", + entity_sim_threshold=0.95 + ), + value_sim_threshold=0.5 + ) + tg = KgManager.load_kg(get_generated_kg()) + metric_result : MetricResult = TripleAlignmentMetric().compute(tg, config) + print(render_metric_result(metric_result)) + diff --git a/src/kgpipe_eval/test/utils.py b/src/kgpipe_eval/test/utils.py index afe8034..855b957 100644 --- a/src/kgpipe_eval/test/utils.py +++ b/src/kgpipe_eval/test/utils.py @@ -24,6 +24,16 @@ def get_test_kg(sample_size: int = -1) -> KG: g.serialize(destination=tmp_dir / "test.nt", format="ntriples") return KG("test", name="test", path=tmp_dir / "test.nt", format=DataFormat.RDF_NTRIPLES) +def get_generated_kg(sample_size: int = -1) -> KG: + generated_triples = GENERATED_TURTLE_TRIPLES + if sample_size > 0: + generated_triples = generated_triples[:sample_size] + # write generated_triples to a file + g = Graph() + g.parse(data=generated_triples, format="turtle") + g.serialize(destination=tmp_dir / "generated.nt", format="ntriples") + return KG("generated", name="generated", path=tmp_dir / "generated.nt", format=DataFormat.RDF_NTRIPLES) + def get_reference_kg(sample_size: int = -1) -> KG: reference_triples = REFERENCE_TURTLE_TRIPLES if sample_size > 0: diff --git a/src/kgpipe_eval/utils/alignment_utils.py b/src/kgpipe_eval/utils/alignment_utils.py index be87a96..1e4f16c 100644 --- a/src/kgpipe_eval/utils/alignment_utils.py +++ b/src/kgpipe_eval/utils/alignment_utils.py @@ -1,5 +1,6 @@ +from transformers.models.t5gemma2.modeling_t5gemma2 import T5Gemma2ClassificationHead from kgpipe.common import KG -from typing import Literal, NamedTuple, Optional +from typing import TYPE_CHECKING, Literal, NamedTuple, Optional from functools import lru_cache from pydantic import BaseModel, ConfigDict, model_validator @@ -7,13 +8,19 @@ from kgpipe.util.embeddings.st_emb import get_model from rdflib import RDFS, RDF +from rdflib.term import BNode +from rdflib.term import Literal as RdLiteral from kgpipe.datasets.multipart_multisource import read_entities_csv, EntitiesRow import numpy as np from pathlib import Path +from tqdm import tqdm +from tqdm import tqdm from typing import Set # TODO source entities csv to label only graph +DEBUG = True + class EntityAlignmentConfig(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) method: Literal["label_embedding", "label_alias_embedding", "label_embedding_and_type", "label_embedding_and_intersecting_type"] = "label_embedding" @@ -127,3 +134,239 @@ def align_entities_by_label_embedding(tg: TripleGraph, config: EntityAlignmentCo def align_by_label_alias_embedding(triple_graph: TripleGraph, model="", similarity="cosine", threshold=0.5): pass + + +if TYPE_CHECKING: # avoid circular import at runtime + from kgpipe_eval.metrics.triple_alignment import TripleAlignmentConfig + + +def _is_literal(term: Term) -> bool: + return isinstance(term, RdLiteral) + + +def _literal_text(lit: RdLiteral) -> str: + # Prefer lexical form; fall back to python value string. + try: + return str(lit) + except Exception: + return str(lit.toPython()) + + +def align_triples_by_value_embedding(tg: TripleGraph, config: "TripleAlignmentConfig") -> list[TripleAlignment]: + """ + Align generated triples in `tg` to reference triples using: + - entity alignment (for URI/BNode subjects/objects) + - embedding similarity for literal object values (for same subject+predicate) + """ + ref_tg = KgManager.load_kg(config.reference_kg) + + # 0) Blank node mapping. + # + # rdflib assigns fresh IDs to BNodes on parse/load, so loading the "same" KG + # twice will not preserve BNode identifiers. We map BNodes by an outgoing-edge + # signature (predicate + object lexical form) to make exact-equal graphs align. + def _term_key(t: Term) -> str: + return str(t) + + def _bnode_signature(g: TripleGraph, b: BNode) -> tuple[tuple[str, str], ...]: + pairs: list[tuple[str, str]] = [] + for _, p, o in g.triples((b, None, None)): + if _is_literal(o): + ok = _literal_text(o) + else: + ok = _term_key(o) + pairs.append((_term_key(p), ok)) + pairs.sort() + return tuple(pairs) + + def _build_bnode_map(gen_g: TripleGraph, ref_g: TripleGraph) -> dict[str, Term]: + ref_by_sig: dict[tuple[tuple[str, str], ...], list[BNode]] = {} + for s, _, _ in ref_g.triples((None, None, None)): + if isinstance(s, BNode): + print(f"Ref bnode: {s}") + sig = _bnode_signature(ref_g, s) + ref_by_sig.setdefault(sig, []).append(s) + + gen_by_sig: dict[tuple[tuple[str, str], ...], list[BNode]] = {} + for s, _, _ in gen_g.triples((None, None, None)): + if isinstance(s, BNode): + print(f"Gen bnode: {s}") + sig = _bnode_signature(gen_g, s) + gen_by_sig.setdefault(sig, []).append(s) + + # Accept signature matches. If a signature occurs multiple times in both graphs, + # map deterministically by sorting node IDs and zipping. This makes identical KGs + # align even when they contain repeated blank-node structures. + out: dict[str, Term] = {} + for sig, gen_nodes in gen_by_sig.items(): + ref_nodes = ref_by_sig.get(sig, []) + if not ref_nodes: + continue + if len(gen_nodes) != len(ref_nodes): + continue + for gnode, rnode in zip(sorted(gen_nodes, key=_term_key), sorted(ref_nodes, key=_term_key)): + out[_term_key(gnode)] = rnode + return out + + gen_bnode_to_ref: dict[str, Term] = _build_bnode_map(tg, ref_tg) + + # 1) Entity alignments (generated -> reference) + ent_cfg = config.entity_alignment_config + if getattr(ent_cfg, "reference_kg", None) is None and getattr(ent_cfg, "verified_entities_path", None) is None: + # Ensure validator requirements are met; default to using the reference KG. + ent_cfg = ent_cfg.model_copy(update={"reference_kg": config.reference_kg}) + + entity_alignments = align_entities_by_label_embedding(tg, ent_cfg) + + if DEBUG: print("Entity alignments: ", len(entity_alignments)) + + gen_to_ref_entity: dict[str, Term] = {} + best_score_by_gen: dict[str, float] = {} + for a in entity_alignments: + gen_key = str(a.source) + if gen_key not in best_score_by_gen or a.score > best_score_by_gen[gen_key]: + best_score_by_gen[gen_key] = float(a.score) + gen_to_ref_entity[gen_key] = a.target + + # 2) Index generated triples, both raw and entity-mapped + mapped_gen_triples: list[tuple[Triple, Triple]] = [] # (raw_gen, mapped_to_ref_space) + gen_by_sp_literal: dict[tuple[Term, Term], list[tuple[Triple, str]]] = {} + gen_by_sp_entity: dict[tuple[Term, Term], set[Triple]] = {} + + if DEBUG: print("Gen by sp literal: ", len(gen_by_sp_literal)) + if DEBUG: print("Gen by sp entity: ", len(gen_by_sp_entity)) + + sp_iter = getattr(tg, "iter_sp_groups", None) + if callable(sp_iter): + sp_groups = sp_iter() + for s, p, os in sp_groups: + for o in os: + mapped_s = gen_to_ref_entity.get(str(s), gen_bnode_to_ref.get(str(s), s)) + mapped_o = gen_to_ref_entity.get(str(o), gen_bnode_to_ref.get(str(o), o)) if not _is_literal(o) else o + mapped = (mapped_s, p, mapped_o) + raw = (s, p, o) + mapped_gen_triples.append((raw, mapped)) + + # Normalize keys to string form to avoid rdflib Term vs string mismatches. + sp = (_term_key(mapped_s), _term_key(p)) + if _is_literal(o): + gen_by_sp_literal.setdefault(sp, []).append((raw, _literal_text(o))) + else: + gen_by_sp_entity.setdefault(sp, set()).add(raw) + else: + for s, p, o in tg.triples((None, None, None)): + mapped_s = gen_to_ref_entity.get(str(s), gen_bnode_to_ref.get(str(s), s)) + mapped_o = gen_to_ref_entity.get(str(o), gen_bnode_to_ref.get(str(o), o)) if not _is_literal(o) else o + mapped = (mapped_s, p, mapped_o) + raw = (s, p, o) + mapped_gen_triples.append((raw, mapped)) + + # Normalize keys to string form to avoid rdflib Term vs string mismatches. + sp = (_term_key(mapped_s), _term_key(p)) + if _is_literal(o): + gen_by_sp_literal.setdefault(sp, []).append((raw, _literal_text(o))) + else: + gen_by_sp_entity.setdefault(sp, set()).add(raw) + + if DEBUG: print("Mapped gen triples: ", len(mapped_gen_triples)) + + # 3) Prepare literal embedding caches (optional) + model = get_model() + alignments: list[TripleAlignment] = [] + + # Encode generated literal texts once, cache by text. + cache_gen_literals = bool(getattr(config, "cache_literal_embeddings", True)) + gen_lit_emb_by_text: dict[str, np.ndarray] = {} + if cache_gen_literals and gen_by_sp_literal: + unique_texts = sorted({txt for candidates in gen_by_sp_literal.values() for _, txt in candidates}) + if unique_texts: + emb = model.encode(unique_texts, convert_to_numpy=True, show_progress_bar=True) + gen_lit_emb_by_text = {t: emb[i : i + 1] for i, t in enumerate(unique_texts)} + + # Reference literal embedding cache (by text). + cache_ref_literals = bool(getattr(config, "cache_ref_literal_embeddings", True)) + ref_lit_emb_by_text: dict[str, np.ndarray] = {} + if cache_ref_literals: + unique_texts = sorted({_literal_text(ro) for _, _, ro in ref_tg.triples((None, None, None))}) + if unique_texts: + emb = model.encode(unique_texts, convert_to_numpy=True, show_progress_bar=True) + ref_lit_emb_by_text = {t: emb[i : i + 1] for i, t in enumerate(unique_texts)} + + def get_ref_literal_embedding(texts: list[str]) -> np.ndarray: + if cache_ref_literals and ref_lit_emb_by_text: + return np.concatenate([ref_lit_emb_by_text[t] for t in texts], axis=0) + else: + return model.encode(texts, convert_to_numpy=True, show_progress_bar=True) + + + def get_gen_literal_embedding(texts: list[str]) -> np.ndarray: + if cache_gen_literals and gen_lit_emb_by_text: + return np.concatenate([gen_lit_emb_by_text[t] for t in texts], axis=0) + else: + return model.encode(texts, convert_to_numpy=True, show_progress_bar=True) + + if DEBUG: print("gen_lit_emb_by_text: ", len(gen_lit_emb_by_text)) + if DEBUG: print("ref_lit_emb_by_text: ", len(ref_lit_emb_by_text)) + + from rdflib import Graph, URIRef + gen_graph : Graph = tg._graph() + ref_graph : Graph = ref_tg._graph() + + test_objects = list(ref_graph.objects(URIRef("http://kg.org/resource/f4eb17c4ed78c87c29124018c9f180b5"), URIRef("http://kg.org/ontology/deathPlace"), unique=True)) + if DEBUG: print("Test objects: ", len(test_objects)) + + if DEBUG: print("Gen graph: ", len(list(gen_graph.triples((None, None, None))))) + if DEBUG: print("Ref graph: ", len(list(ref_graph.triples((None, None, None))))) + + for gs, gp in tqdm(gen_graph.subject_predicates(unique=True), desc="Aligning triples by value embedding"): + # sp = (_term_key(gs), _term_key(gp)) + + # check for s mapping in reference space + ref_s = gen_to_ref_entity.get(str(gs), gen_bnode_to_ref.get(str(gs), gs)) + if ref_s is None: + continue # s is not mapped to reference space + + gen_objects = list(gen_graph.objects(gs, gp)) + gen_literal_objs = [o for o in gen_objects if _is_literal(o)] + + # IMPORTANT: query reference objects in reference-space subject + ref_objects = list(ref_graph.objects(URIRef(str(ref_s)), URIRef(str(gp)))) + ref_literal_objs = [o for o in ref_objects if _is_literal(o)] + + # print("gs: ", gs, "gp: ", gp) + # print("ref_s: ", ref_s) + # print("gen_literal_objs: ", len(gen_literal_objs)) + # print("ref_literal_objs: ", len(ref_literal_objs)) + # print("gen_objects: ", len(gen_objects)) + # print("ref_objects: ", len(ref_objects)) + + if len(gen_literal_objs) > 0 and len(ref_literal_objs) > 0: + + gen_object_texts = [_literal_text(o) for o in gen_literal_objs] + ref_object_texts = [_literal_text(o) for o in ref_literal_objs] + + gen_object_embeddings = get_gen_literal_embedding(gen_object_texts) + ref_object_embeddings = get_ref_literal_embedding(ref_object_texts) + + sims = np.dot(gen_object_embeddings, ref_object_embeddings.T) # shape (n_gen, n_ref) + best_flat = int(np.argmax(sims)) + best_i, best_j = np.unravel_index(best_flat, sims.shape) + + if float(sims[best_i, best_j]) >= float(config.value_sim_threshold): + alignments.append( + TripleAlignment( + source=(gs, gp, gen_literal_objs[best_i]), + target=(ref_s, gp, ref_literal_objs[best_j]), + ) + ) + + # get all non-literal objects mapped to reference space + gen_object_non_literal = [o for o in gen_objects if not _is_literal(o)] + ref_object_non_literal = [o for o in ref_objects if not _is_literal(o)] + + # find if any of the non-literal objects in the generated graph are mapped to the same object in the reference graph + for gen_obj in gen_object_non_literal: + if gen_to_ref_entity.get(str(gen_obj), gen_bnode_to_ref.get(str(gen_obj), gen_obj)) in ref_object_non_literal: + alignments.append(TripleAlignment(source=(gs, gp, gen_obj), target=(gs, gp, ref_object_non_literal[ref_object_non_literal.index(gen_obj)]))) + + return alignments \ No newline at end of file diff --git a/src/kgpipe_eval/utils/kg_utils.py b/src/kgpipe_eval/utils/kg_utils.py index 09b3410..1032af2 100644 --- a/src/kgpipe_eval/utils/kg_utils.py +++ b/src/kgpipe_eval/utils/kg_utils.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from pathlib import Path from typing import Iterable, Protocol, Union, runtime_checkable, Optional, Tuple, Literal +from collections import defaultdict from rdflib import RDF, Graph, RDFS from rdflib.term import Identifier, Literal, URIRef @@ -121,6 +122,25 @@ def subjects(self) -> Iterable[Term]: g = self._graph() return g.subjects(unique=True) + def iter_sp_groups(self) -> Iterable[tuple[Term, Term, list[Term]]]: + """Yield (s, p, [o1, o2, ...]) for all subjects/predicates.""" + g = self._graph() + by_sp: dict[tuple[Term, Term], list[Term]] = defaultdict(list) + for s, p, o in g.triples((None, None, None)): + by_sp[(s, p)].append(o) + for (s, p), objs in by_sp.items(): + yield (s, p, objs) + + def subject_predicate_pairs(self) -> Iterable[tuple[Term, Term]]: + """Yield (s, p) for all subjects/predicates.""" + g = self._graph() + return g.subject_predicates(unique=True) + + def objects(self, subject: Term, predicate: Term) -> Iterable[Term]: + """Yield (o) for all objects of (s, p).""" + g = self._graph() + return g.objects(subject, predicate) + def entities(self) -> Iterable[Term]: return self.subjects() # TODO inlcude objects that are not subjects diff --git a/src/kgpipe_llm/common/core.py b/src/kgpipe_llm/common/core.py index d1ee620..7c3af9f 100644 --- a/src/kgpipe_llm/common/core.py +++ b/src/kgpipe_llm/common/core.py @@ -3,7 +3,7 @@ """ import os -from typing import Any, Dict, Generic, Optional, TypeVar +from typing import Any, Dict, Generic, Optional, TypeVar, cast from dotenv import load_dotenv from pydantic import BaseModel @@ -92,7 +92,7 @@ def send_prompt( ) print(f"INFO: {self.api_type}_call_with_tool {type(schema_class)}") - dict_val, _model_val = tool_call( + dict_val, model_val = tool_call( endpoint_url=endpoint, api_key=self.token, model_name=self.model_name, @@ -101,10 +101,16 @@ def send_prompt( system_prompt=system_prompt, seed=self.seed, ) + # Prefer returning the validated Pydantic instance when possible. + if isinstance(schema_class, type) and issubclass(schema_class, BaseModel): + if isinstance(model_val, BaseModel): + return model_val + if isinstance(dict_val, dict): + return cast(type[T], schema_class).model_validate(dict_val) return dict_val print(f"INFO: ollama_call with {type(schema_class)}") - return ollama_call( + raw_val = ollama_call( endpoint_url=self.endpoint_url, api_key=self.token, model_name=self.model_name, @@ -113,6 +119,10 @@ def send_prompt( system_prompt=system_prompt, seed=self.seed, ) + # Ollama path currently returns raw JSON; upgrade to a validated model when requested. + if isinstance(schema_class, type) and issubclass(schema_class, BaseModel) and isinstance(raw_val, dict): + return cast(type[T], schema_class).model_validate(raw_val) + return raw_val class BaseTask(Generic[T]): From 71b0db873127c0c6e8a8b875f06151923e1ac039 Mon Sep 17 00:00:00 2001 From: Marvin Date: Tue, 12 May 2026 16:30:32 +0200 Subject: [PATCH 32/42] exp(params): final selection of pipelines --- .../src/param_opti/tasks/base_linker.py | 4 +- .../src/param_opti/tasks/base_linker_lib.py | 2 + .../src/param_opti/tasks/base_matcher.py | 1 + .../param-opti/src/param_opti/tasks/genie.py | 23 +- .../src/param_opti/tasks/genie_lib.py | 89 ++++++++ .../param-opti/src/param_opti/tasks/paris.py | 45 +++- .../src/param_opti/tasks/spotlight.py | 29 ++- .../src/param_opti/tasks/spotlight_lib.py | 83 ++------ .../src/param_opti/tasks/text_helpers.py | 37 +++- .../rdf_sampled_pipeline_configs.json | 64 ++---- .../text_sampled_pipeline_configs.json | 153 +++++++++++++ ...eline_config.py => test_conf_pipelines.py} | 201 +++++++++++++++--- .../param-opti/src/qap/test_eval_pipelines.py | 122 +++++++++++ .../param-opti/src/qap/test_exec_pipelines.py | 52 ++++- src/kgpipe_eval/utils/alignment_utils.py | 32 ++- 15 files changed, 753 insertions(+), 184 deletions(-) create mode 100644 experiments/param-opti/src/qap/fixtures/text_sampled_pipeline_configs.json rename experiments/param-opti/src/qap/{test_pipeline_config.py => test_conf_pipelines.py} (74%) create mode 100644 experiments/param-opti/src/qap/test_eval_pipelines.py diff --git a/experiments/param-opti/src/param_opti/tasks/base_linker.py b/experiments/param-opti/src/param_opti/tasks/base_linker.py index 1d09432..aee7932 100644 --- a/experiments/param-opti/src/param_opti/tasks/base_linker.py +++ b/experiments/param-opti/src/param_opti/tasks/base_linker.py @@ -16,7 +16,7 @@ def relation_linker_label_alias_embedding_transformer_function(inputs: TaskInput config_spec=ConfigurationDefinition( name="relation_linker_label_alias_embedding_transformer", parameters=[ - Parameter(name="model_name", native_keys=["--model-name"], datatype=ParameterType.string, default_value="sentence-transformers/all-MiniLM-L6-v2", required=True, allowed_values=["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"]), + Parameter(name="model_name", native_keys=["--model-name"], datatype=ParameterType.string, default_value="sentence-transformers/all-MiniLM-L6-v2", required=True, allowed_values=["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "intfloat/e5-base-v2"]), Parameter(name="similarity_threshold", native_keys=["--similarity-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), ] ) @@ -37,7 +37,7 @@ def entity_linker_label_alias_embedding_transformer_function(inputs: TaskInput, config_spec=ConfigurationDefinition( name="entity_linker_label_alias_embedding_transformer", parameters=[ - Parameter(name="model_name", native_keys=["--model-name"], datatype=ParameterType.string, default_value="sentence-transformers/all-MiniLM-L6-v2", required=True, allowed_values=["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"]), + Parameter(name="model_name", native_keys=["--model-name"], datatype=ParameterType.string, default_value="sentence-transformers/all-MiniLM-L6-v2", required=True, allowed_values=["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "intfloat/e5-base-v2"]), Parameter(name="similarity_threshold", native_keys=["--similarity-threshold"], datatype=ParameterType.number, default_value=0.5, required=True, allowed_values=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]), ] ) diff --git a/experiments/param-opti/src/param_opti/tasks/base_linker_lib.py b/experiments/param-opti/src/param_opti/tasks/base_linker_lib.py index 680a575..1b0b12d 100644 --- a/experiments/param-opti/src/param_opti/tasks/base_linker_lib.py +++ b/experiments/param-opti/src/param_opti/tasks/base_linker_lib.py @@ -156,6 +156,7 @@ class AliasAndTransformerBasedRelationLinker: """ def __init__(self, ontology_file, model_name: str = "all-MiniLM-L6-v2", threshold: float = 0.0): + print(f"Init AliasAndTransformerBasedRelationLinker with ontology file: {ontology_file} and model name: {model_name} and threshold: {threshold}") self.ontology = OntologyUtil.load_ontology_from_file(ontology_file) self.embedder = SentenceTransformerEmbedder(model_name=model_name) self.threshold = float(threshold) @@ -175,6 +176,7 @@ def link_relations(self, extracted_relations: List[str]) -> List[RelationMatch]: for i, relation in enumerate(extracted_relations): best_idx = int(similarities[i].argmax()) best_score = float(similarities[i][best_idx]) + # print(f"Relation: {relation}, matched to: {self.ontology.properties[best_idx].uri}, label: {self.ontology.properties[best_idx].label}, Best Index: {best_idx}, Best Score: {best_score}") if best_score < self.threshold: continue match = self.ontology.properties[best_idx] diff --git a/experiments/param-opti/src/param_opti/tasks/base_matcher.py b/experiments/param-opti/src/param_opti/tasks/base_matcher.py index a43bc45..223fdc4 100644 --- a/experiments/param-opti/src/param_opti/tasks/base_matcher.py +++ b/experiments/param-opti/src/param_opti/tasks/base_matcher.py @@ -19,6 +19,7 @@ def _embedding_config_params(): allowed_values=[ "sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", + "intfloat/e5-base-v2", ], ), Parameter( diff --git a/experiments/param-opti/src/param_opti/tasks/genie.py b/experiments/param-opti/src/param_opti/tasks/genie.py index 1abf7c6..a97b566 100644 --- a/experiments/param-opti/src/param_opti/tasks/genie.py +++ b/experiments/param-opti/src/param_opti/tasks/genie.py @@ -1,9 +1,28 @@ from typing import Dict, Any from kgpipe.common import Data, DataFormat, Registry, KgTask - +from pathlib import Path def genie_text_extraction_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): - pass + from param_opti.tasks.genie_lib import genie_task_docker, genie_exchange + + # Ensure parent directory exists for the TE JSON output path + outputs["output"].path.parent.mkdir(parents=True, exist_ok=True) + + input_path: Path = inputs["input"].path + final_te_output: Data = outputs["output"] + + # 1) Produce intermediate OpenIE JSON (file or directory) + if input_path.is_dir(): + genie_out_path = final_te_output.path.parent / f"{final_te_output.path.stem}_corenlp_openie_out" + else: + genie_out_path = final_te_output.path.parent / f"{final_te_output.path.stem}_corenlp_openie.json" + + genie_outpit = {"output": Data(genie_out_path, DataFormat.OPENIE_JSON)} + genie_task_docker({"input": inputs["input"]}, genie_outpit) + + # 2) Convert OpenIE JSON → TE JSON (final output) + genie_exchange({"input": genie_outpit["output"]}, {"output": final_te_output}) + genie_text_extraction_task = KgTask( name="genie_text_extraction", diff --git a/experiments/param-opti/src/param_opti/tasks/genie_lib.py b/experiments/param-opti/src/param_opti/tasks/genie_lib.py index e69de29..0806f37 100644 --- a/experiments/param-opti/src/param_opti/tasks/genie_lib.py +++ b/experiments/param-opti/src/param_opti/tasks/genie_lib.py @@ -0,0 +1,89 @@ + +import re +import json +import os +from typing import Dict +from kgpipe.common import Data, TaskInput, TaskOutput +from kgpipe.common import KgTask, DataFormat, Data, Registry, TaskInput, TaskOutput +from kgpipe.common.io import get_docker_volume_bindings, remap_data_path_for_container +from kgpipe.execution import docker_client +from kgpipe_tasks.transform_interop.exchange.entity_matching import ER_Match, ER_Document + + +def genie_task_docker(inputs: TaskInput, outputs: TaskOutput): + """ + GenIE information extraction task that runs in a Docker container. + + Args: + inputs: Dictionary mapping input names to Data objects + outputs: Dictionary mapping output names to Data objects + """ + + all_data = list(inputs.values()) + list(outputs.values()) + volumes, host_to_container = get_docker_volume_bindings(all_data) + + source_path = remap_data_path_for_container(inputs["input"], host_to_container) + output_path = remap_data_path_for_container(outputs["output"], host_to_container) + + client = docker_client( + image="genie:latest", + command=["genie.sh", + str(source_path.path), + str(output_path.path)], + volumes=volumes, + ) + + result = client() + print(f"GenIE completed: {result}") + +def process_io(input_path, output_path, process_file_fn, extension): + if os.path.isdir(input_path): + os.makedirs(output_path, exist_ok=True) + + for filename in os.listdir(input_path): + input_file = os.path.join(input_path, filename) + + if not os.path.isfile(input_file): + continue + + output_file = os.path.join( + output_path, + os.path.splitext(filename)[0] + extension + ) + + process_file_fn(input_file, output_file) + + else: + process_file_fn(input_path, output_path) + +def genie_exchange(inputs: Dict[str, Data], outputs: Dict[str, Data]): + input_path = inputs["input"].path + output_path = outputs["output"].path + + triple_pattern = re.compile( + r"\s*(.*?)\s*\s*(.*?)\s*\s*(.*?)\s*" + ) + + def exchange_file(input_file, output_file): + triples = [] + chains = [] + + with open(input_file, "r", encoding="utf-8") as f: + genie_output = json.load(f) + + for sentence in genie_output: + for beam in sentence: + text = beam.get("text", "") + matches = triple_pattern.findall(text) + + for subj, pred, obj in matches: + triples.append({ + "subject": {"surface_form": subj.strip()}, + "predicate": {"surface_form": pred.strip()}, + "object": {"surface_form": obj.strip()} + }) + + with open(output_file, "w", encoding="utf-8") as f: + json.dump({"triples": triples, "chains": chains}, f, indent=2) + + process_io(input_path, output_path, exchange_file, ".te.json") \ No newline at end of file diff --git a/experiments/param-opti/src/param_opti/tasks/paris.py b/experiments/param-opti/src/param_opti/tasks/paris.py index 4fb5772..85e2b27 100644 --- a/experiments/param-opti/src/param_opti/tasks/paris.py +++ b/experiments/param-opti/src/param_opti/tasks/paris.py @@ -9,8 +9,27 @@ def paris_entity_alignment_function(inputs: TaskInput, outputs: TaskOutput, conf matches entities between two RDF graphs """ # touch output file - print(f"paris_entity_alignment_function: {outputs['output'].path}") - outputs["output"].path.touch() + from param_opti.tasks.paris_lib import paris_exchange, paris_entity_matching + entity_matching_threshold = float(config.get_parameter_value("entity_matching_threshold")) + relation_matching_threshold = float(2) # todo skip all matches + + # Ensure parent directory exists for the ER JSON output file + outputs["output"].path.parent.mkdir(parents=True, exist_ok=True) + + # 1 produce matches in paris csv format + matching_dir = outputs["output"].path.parent / f"{outputs['output'].path.stem}_paris_out" + matching_output = {"output": Data(matching_dir, DataFormat.PARIS_CSV)} + + # paris_entity_matching expects {"source": ..., "kg": ...} + paris_entity_matching({"source": inputs["source"], "kg": inputs["target"]}, matching_output) + + # 2 convert paris output dir to er.json format (file) + paris_exchange( + matching_output["output"].path, + outputs["output"].path, + entity_matching_threshold, + relation_matching_threshold, + ) paris_entity_alignment_task = KgTask( name="paris_entity_alignment", @@ -71,7 +90,27 @@ def paris_ontology_matching_function(inputs: TaskInput, outputs: TaskOutput, con matches ontologies between two RDF graphs """ # touch output file - outputs["output"].path.touch() + from param_opti.tasks.paris_lib import paris_exchange, paris_entity_matching + entity_matching_threshold = float(2) # todo skip all matches + ontology_matching_threshold = float(config.get_parameter_value("ontology_matching_threshold")) + + # Ensure parent directory exists for the ER JSON output file + outputs["output"].path.parent.mkdir(parents=True, exist_ok=True) + + # 1 produce matches in paris csv format + matching_dir = outputs["output"].path.parent / f"{outputs['output'].path.stem}_paris_out" + matching_output = {"output": Data(matching_dir, DataFormat.PARIS_CSV)} + + # paris_entity_matching expects {"source": ..., "kg": ...} + paris_entity_matching({"source": inputs["source"], "kg": inputs["target"]}, matching_output) + + # 2 convert paris output dir to er.json format (file) + paris_exchange( + matching_output["output"].path, + outputs["output"].path, + entity_matching_threshold, + ontology_matching_threshold, + ) paris_ontology_matching_task = KgTask( name="paris_ontology_matching", diff --git a/experiments/param-opti/src/param_opti/tasks/spotlight.py b/experiments/param-opti/src/param_opti/tasks/spotlight.py index 213cef4..3129ad0 100644 --- a/experiments/param-opti/src/param_opti/tasks/spotlight.py +++ b/experiments/param-opti/src/param_opti/tasks/spotlight.py @@ -1,12 +1,31 @@ from typing import Dict, Any from kgpipe.common import Data, DataFormat, Registry, KgTask -from kgpipe.common.model.configuration import ConfigurationDefinition, Parameter, ParameterType +from kgpipe.common.model.configuration import ConfigurationDefinition, ConfigurationProfile, Parameter, ParameterType +from pathlib import Path + +def spotlight_entity_linking_function(inputs: Dict[str, Data], outputs: Dict[str, Data], config: ConfigurationProfile ): + from param_opti.tasks.spotlight_lib import dbpedia_spotlight_ner_nel, dbpedia_spotlight_exchange + + # Ensure parent directory exists for the TE JSON output path + outputs["output"].path.parent.mkdir(parents=True, exist_ok=True) + + input_path: Path = inputs["input"].path + final_te_output: Data = outputs["output"] + + # 1) Produce intermediate OpenIE JSON (file or directory) + if input_path.is_dir(): + spotlight_out_path = final_te_output.path.parent / f"{final_te_output.path.stem}_corenlp_openie_out" + else: + spotlight_out_path = final_te_output.path.parent / f"{final_te_output.path.stem}_corenlp_openie.json" + + spotlight_out = {"output": Data(spotlight_out_path, DataFormat.OPENIE_JSON)} + if not spotlight_out_path.exists(): + dbpedia_spotlight_ner_nel({"input": inputs["input"]}, spotlight_out) + + # 2) Convert OpenIE JSON → TE JSON (final output) + dbpedia_spotlight_exchange({"input": spotlight_out["output"]}, {"output": final_te_output}, config.get_parameter_value("similarity_threshold")) -def spotlight_entity_linking_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): - from param_opti.tasks.spotlight_lib import dbpedia_spotlight_ner_nel, dbpedia_spotlight_exchange_filtered - dbpedia_spotlight_ner_nel({"input": inputs["input"]}, {"output": outputs["output"]}) - dbpedia_spotlight_exchange_filtered({"source": outputs["output"]}, {"output": outputs["output"]}) spotlight_entity_linking_task = KgTask( name="spotlight_entity_linking", diff --git a/experiments/param-opti/src/param_opti/tasks/spotlight_lib.py b/experiments/param-opti/src/param_opti/tasks/spotlight_lib.py index 277de80..0bba12b 100644 --- a/experiments/param-opti/src/param_opti/tasks/spotlight_lib.py +++ b/experiments/param-opti/src/param_opti/tasks/spotlight_lib.py @@ -22,7 +22,7 @@ HEADERS = { "Accept": "application/json" } - +DEFAULT_API_URL = "http://localhost:2222/rest/annotate" def api_request(url: str, text: str) -> Dict[str, Any]: """Make API request to DBpedia Spotlight.""" @@ -42,18 +42,12 @@ def api_request(url: str, text: str) -> Dict[str, Any]: return result -@Registry.task( - input_spec={"input": DataFormat.TEXT}, - output_spec={"output": DataFormat.SPOTLIGHT_JSON}, - description="Link entities using DBpedia Spotlight API", - category=["TextProcessing", "EntityLinking"] -) def dbpedia_spotlight_ner_nel(inputs: Dict[str, Data], outputs: Dict[str, Data]): """Link entities using DBpedia Spotlight API.""" input_data = inputs["input"] output_data = outputs["output"] - DBPEDIA_ANNOTATE_URL = os.getenv("DBPEDIA_ANNOTATE_URL") + DBPEDIA_ANNOTATE_URL = os.getenv("DBPEDIA_ANNOTATE_URL", DEFAULT_API_URL) if not DBPEDIA_ANNOTATE_URL: raise ValueError("Missing DBpedia ANnotate URL") @@ -79,70 +73,15 @@ def dbpedia_spotlight_ner_nel(inputs: Dict[str, Data], outputs: Dict[str, Data]) f.write(json.dumps(results)) -@Registry.task( - input_spec={"source": DataFormat.SPOTLIGHT_JSON}, - output_spec={"output": DataFormat.TE_JSON}, - description="Convert Spotlight JSON to TE JSON format", - category=["TextProcessing", "EntityLinking"] -) -def dbpedia_spotlight_exchange_filtered(inputs: Dict[str, Data], outputs: Dict[str, Data]): - """Convert Spotlight JSON to TE JSON format.""" - input_path = inputs["source"].path - output_path = outputs["output"].path - - - - # create output folder - os.makedirs(os.path.normpath(output_path), exist_ok=True) - - def __spotlightjson2tejson(data) -> Dict[str, Any]: - """Convert Spotlight JSON to TE Document format.""" - links = [] - - for result in data.get('Resources', []): - link = { - "span": result.get('@surfaceForm', ''), - "mapping": result.get('@URI', ''), - "score": float(result.get('@similarityScore', 0.0)), - "link_type": "entity" - } - links.append(link) - - text = data.get('@text', '') - return {"text": text, "links": links} - - if os.path.isdir(input_path): - for file in os.listdir(input_path): - # Read input json - with open(os.path.join(input_path, file), 'r') as f: - data = json.load(f) - te_doc = __spotlightjson2tejson(data) - outfile = os.path.join(output_path, file) - - with open(outfile, 'w') as of: - json.dump(te_doc, of) - # print(f"Converted {file} to {outfile}") - - else: - # Read input json - with open(input_path, 'r') as f: - data = json.load(f) - te_doc = __spotlightjson2tejson(data) - outfile = os.path.join(output_path, 'output.te.json') - with open(outfile, 'w') as of: - json.dump(te_doc, of) - # print(f"Converted {input_path} to {output_path}") - - -@Registry.task( - input_spec={"source": DataFormat.SPOTLIGHT_JSON}, - output_spec={"output": DataFormat.TE_JSON}, - description="Convert Spotlight JSON to TE JSON format, with seed filter", - category=["TextProcessing", "EntityLinking"] -) -def dbpedia_spotlight_exchange(inputs: Dict[str, Data], outputs: Dict[str, Data]): +# @Registry.task( +# input_spec={"source": DataFormat.SPOTLIGHT_JSON}, +# output_spec={"output": DataFormat.TE_JSON}, +# description="Convert Spotlight JSON to TE JSON format, with seed filter", +# category=["TextProcessing", "EntityLinking"] +# ) +def dbpedia_spotlight_exchange(inputs: Dict[str, Data], outputs: Dict[str, Data], threshold: float = 0.5): """Convert Spotlight JSON to TE JSON format.""" - input_path = inputs["source"].path + input_path = inputs["input"].path output_path = outputs["output"].path # create output folder @@ -153,6 +92,8 @@ def __spotlightjson2tejson(data) -> Dict[str, Any]: links = [] for result in data.get('Resources', []): + if float(result.get('@similarityScore', 0.0)) < threshold: + continue link = { "span": result.get('@surfaceForm', ''), "mapping": result.get('@URI', ''), diff --git a/experiments/param-opti/src/param_opti/tasks/text_helpers.py b/experiments/param-opti/src/param_opti/tasks/text_helpers.py index 4f5d776..0bef3f4 100644 --- a/experiments/param-opti/src/param_opti/tasks/text_helpers.py +++ b/experiments/param-opti/src/param_opti/tasks/text_helpers.py @@ -18,6 +18,8 @@ logger = logging.getLogger(__name__) + + def __aggregate_x_te_json(input_paths: List[Path], output_path: Path): if len(input_paths) == 0: @@ -65,23 +67,44 @@ def aggregate3_text_tasks_task_function(inputs: Dict[str, Data], outputs: Dict[s __aggregate_x_te_json([inputs["json1"].path, inputs["json2"].path, inputs["json3"].path], outputs["output"].path) aggregate_text_tasks_task = KgTask( - name="aggregate3_text_tasks_task", + name="aggregate_text_tasks_task", input_spec={"json1": DataFormat.TE_JSON, "json2": DataFormat.TE_JSON, "json3": DataFormat.TE_JSON}, output_spec={"output": DataFormat.TE_JSON}, function=aggregate3_text_tasks_task_function ) +def aggregate2_text_tasks_task_function(inputs: Dict[str, Data], outputs: Dict[str, Data]): + __aggregate_x_te_json([inputs["json1"].path, inputs["json2"].path], outputs["output"].path) + +aggregate_entity_linking_task = KgTask( + name="aggregate_entity_linking_task", + input_spec={"json1": DataFormat.TE_JSON, "json2": DataFormat.TE_JSON}, + output_spec={"output": DataFormat.TE_JSON}, + function=aggregate2_text_tasks_task_function +) + +aggregate_relation_linking_task = KgTask( + name="aggregate_relation_linking_task", + input_spec={"json1": DataFormat.TE_JSON, "json2": DataFormat.TE_JSON}, + output_spec={"output": DataFormat.TE_JSON}, + function=aggregate2_text_tasks_task_function +) def generatePredicate(surface_form, namespace): return URIRef(namespace + surface_form.replace(" ", "_")) +def __hash_dbpedia_uri(uri: URIRef, namespace: str = "http://kg.org/resource/"): + if uri.startswith("http://dbpedia.org/"): + return URIRef(namespace + hash_uri(str(uri))) + else: + return uri + def __generateRDF(doc: TE_Document, ontology: Ontology, newP: bool = False, newE: bool = False, namespace: str = "http://kg.org/text/"): """ A processing node, part of a pipeline collects information from extractors, linkers, and resolvers and then it produces the final triples """ - def process_chains(triples, chains: List[TE_Chains]): new_triples = triples chain_dict = {} @@ -180,7 +203,7 @@ def process_links(triples, links: List[TE_Pair]): # print(f"predicate: {predicate}, domain: {domain}, range: {range}") if subject and subject.startswith("http://dbpedia.org"): # TODO workaround for dbpedia... - finalGraph.add((subject, RDFS.label, Literal(triple.subject.surface_form))) + finalGraph.add((__hash_dbpedia_uri(subject), RDFS.label, Literal(triple.subject.surface_form))) if not subject and triple.subject.surface_form and newE: @@ -191,7 +214,10 @@ def process_links(triples, links: List[TE_Pair]): print(f"subject: {subject} {triple.subject.surface_form}") if domain and subject: - finalGraph.add((subject, RDF.type, URIRef(domain))) + finalGraph.add((__hash_dbpedia_uri(subject), RDF.type, URIRef(domain))) + + if object and isObjectProperty and object.startswith("http://dbpedia.org"): # TODO workaround for dbpedia... + finalGraph.add((__hash_dbpedia_uri(object), RDFS.label, Literal(triple.object.surface_form))) if not object and triple.object.surface_form and newE: if isObjectProperty: @@ -208,7 +234,7 @@ def process_links(triples, links: List[TE_Pair]): object = Literal(triple.object.surface_form, datatype=datatype) if(subject and predicate and object): - finalGraph.add((subject, predicate, object)) + finalGraph.add((__hash_dbpedia_uri(subject), predicate, __hash_dbpedia_uri(object))) return finalGraph @@ -220,6 +246,7 @@ def generate_rdf(inputs: Dict[str, Data], outputs: Dict[str, Data], ontology: On for file in os.listdir(dir_or_file): json_data = json.load(open(os.path.join(dir_or_file, file))) doc = TE_Document(**json_data) + print(f"doc: {doc}") for s, p, o in __generateRDF(doc, ontology, newP=newP, newE=newE): graph.add(triple=(s, p, o)) else: diff --git a/experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json b/experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json index a4e16a7..5a745c1 100644 --- a/experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json +++ b/experiments/param-opti/src/qap/fixtures/rdf_sampled_pipeline_configs.json @@ -6,14 +6,14 @@ "bindings": [ { "parameter": "model_name", - "value": "sentence-transformers/all-mpnet-base-v2" + "value": "intfloat/e5-base-v2" }, { "parameter": "similarity_threshold", "value": 0.8 } ], - "profile_name": "graph_alignment_label_alias_embedding_transformer_model_name=sentence-transformers/all-mpnet-base-v2,similarity_threshold=0.8" + "profile_name": "graph_alignment_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.8" } }, "task_keys": [ @@ -27,27 +27,27 @@ "bindings": [ { "parameter": "model_name", - "value": "sentence-transformers/all-mpnet-base-v2" + "value": "intfloat/e5-base-v2" }, { "parameter": "similarity_threshold", "value": 0.8 } ], - "profile_name": "entity_matcher_label_alias_embedding_transformer_model_name=sentence-transformers/all-mpnet-base-v2,similarity_threshold=0.8" + "profile_name": "entity_matcher_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.8" }, "relation_matcher_label_alias_embedding_transformer": { "bindings": [ { "parameter": "model_name", - "value": "sentence-transformers/all-MiniLM-L6-v2" + "value": "intfloat/e5-base-v2" }, { "parameter": "similarity_threshold", - "value": 0.7 + "value": 0.5 } ], - "profile_name": "relation_matcher_label_alias_embedding_transformer_model_name=sentence-transformers/all-MiniLM-L6-v2,similarity_threshold=0.7" + "profile_name": "relation_matcher_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" } }, "task_keys": [ @@ -63,23 +63,23 @@ "bindings": [ { "parameter": "entity_matching_threshold", - "value": 0.7 + "value": 0.9 } ], - "profile_name": "paris_entity_alignment_entity_matching_threshold=0.7" + "profile_name": "paris_entity_alignment_entity_matching_threshold=0.9" }, "relation_matcher_label_alias_embedding_transformer": { "bindings": [ { "parameter": "model_name", - "value": "sentence-transformers/all-mpnet-base-v2" + "value": "intfloat/e5-base-v2" }, { "parameter": "similarity_threshold", - "value": 0.8 + "value": 0.5 } ], - "profile_name": "relation_matcher_label_alias_embedding_transformer_model_name=sentence-transformers/all-mpnet-base-v2,similarity_threshold=0.8" + "profile_name": "relation_matcher_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" } }, "task_keys": [ @@ -95,23 +95,23 @@ "bindings": [ { "parameter": "model_name", - "value": "sentence-transformers/all-MiniLM-L6-v2" + "value": "intfloat/e5-base-v2" }, { "parameter": "similarity_threshold", "value": 0.9 } ], - "profile_name": "entity_matcher_label_alias_embedding_transformer_model_name=sentence-transformers/all-MiniLM-L6-v2,similarity_threshold=0.9" + "profile_name": "entity_matcher_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.9" }, "paris_ontology_matching": { "bindings": [ { "parameter": "ontology_matching_threshold", - "value": 0.9 + "value": 0.5 } ], - "profile_name": "paris_ontology_matching_ontology_matching_threshold=0.9" + "profile_name": "paris_ontology_matching_ontology_matching_threshold=0.5" } }, "task_keys": [ @@ -121,48 +121,20 @@ "fusion_first_value_task" ] }, - { - "profiles": { - "paris_entity_alignment": { - "bindings": [ - { - "parameter": "entity_matching_threshold", - "value": 0.7 - } - ], - "profile_name": "paris_entity_alignment_entity_matching_threshold=0.7" - }, - "paris_ontology_matching": { - "bindings": [ - { - "parameter": "ontology_matching_threshold", - "value": 0.6 - } - ], - "profile_name": "paris_ontology_matching_ontology_matching_threshold=0.6" - } - }, - "task_keys": [ - "paris_ontology_matching_task", - "paris_entity_alignment_task", - "aggregate_matching_results_task", - "fusion_first_value_task" - ] - }, { "profiles": { "paris_graph_alignment": { "bindings": [ { "parameter": "entity_matching_threshold", - "value": 0.6 + "value": 0.9 }, { "parameter": "relation_matching_threshold", "value": 0.5 } ], - "profile_name": "paris_graph_alignment_entity_matching_threshold=0.6,relation_matching_threshold=0.5" + "profile_name": "paris_graph_alignment_entity_matching_threshold=0.9,relation_matching_threshold=0.5" } }, "task_keys": [ diff --git a/experiments/param-opti/src/qap/fixtures/text_sampled_pipeline_configs.json b/experiments/param-opti/src/qap/fixtures/text_sampled_pipeline_configs.json new file mode 100644 index 0000000..e5336cc --- /dev/null +++ b/experiments/param-opti/src/qap/fixtures/text_sampled_pipeline_configs.json @@ -0,0 +1,153 @@ +{ + "samples": [ + { + "profiles": { + "relation_linker_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "intfloat/e5-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.5 + } + ], + "profile_name": "relation_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" + }, + "spotlight_entity_linking": { + "bindings": [ + { + "parameter": "similarity_threshold", + "value": 0.8 + } + ], + "profile_name": "spotlight_entity_linking_similarity_threshold=0.8" + } + }, + "task_keys": [ + "corenlp_text_extraction_task", + "spotlight_entity_linking_task", + "aggregate_entity_linking_task", + "relation_linker_label_alias_embedding_transformer_task", + "aggregate_relation_linking_task", + "generate_rdf_from_text_results_task", + "select_first_value_task" + ] + }, + { + "profiles": { + "entity_linker_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "intfloat/e5-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.9 + } + ], + "profile_name": "entity_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.9" + }, + "relation_linker_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "intfloat/e5-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.5 + } + ], + "profile_name": "relation_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" + } + }, + "task_keys": [ + "corenlp_text_extraction_task", + "entity_linker_label_alias_embedding_transformer_task", + "aggregate_entity_linking_task", + "relation_linker_label_alias_embedding_transformer_task", + "aggregate_relation_linking_task", + "generate_rdf_from_text_results_task", + "select_first_value_task" + ] + }, + { + "profiles": { + "relation_linker_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "intfloat/e5-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.5 + } + ], + "profile_name": "relation_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" + }, + "spotlight_entity_linking": { + "bindings": [ + { + "parameter": "similarity_threshold", + "value": 0.8 + } + ], + "profile_name": "spotlight_entity_linking_similarity_threshold=0.8" + } + }, + "task_keys": [ + "genie_text_extraction_task", + "spotlight_entity_linking_task", + "aggregate_entity_linking_task", + "relation_linker_label_alias_embedding_transformer_task", + "aggregate_relation_linking_task", + "generate_rdf_from_text_results_task", + "select_first_value_task" + ] + }, + { + "profiles": { + "entity_linker_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "intfloat/e5-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.9 + } + ], + "profile_name": "entity_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.9" + }, + "relation_linker_label_alias_embedding_transformer": { + "bindings": [ + { + "parameter": "model_name", + "value": "intfloat/e5-base-v2" + }, + { + "parameter": "similarity_threshold", + "value": 0.5 + } + ], + "profile_name": "relation_linker_label_alias_embedding_transformer_model_name=intfloat/e5-base-v2,similarity_threshold=0.5" + } + }, + "task_keys": [ + "genie_text_extraction_task", + "entity_linker_label_alias_embedding_transformer_task", + "aggregate_entity_linking_task", + "relation_linker_label_alias_embedding_transformer_task", + "aggregate_relation_linking_task", + "generate_rdf_from_text_results_task", + "select_first_value_task" + ] + } + ], + "version": 1 +} diff --git a/experiments/param-opti/src/qap/test_pipeline_config.py b/experiments/param-opti/src/qap/test_conf_pipelines.py similarity index 74% rename from experiments/param-opti/src/qap/test_pipeline_config.py rename to experiments/param-opti/src/qap/test_conf_pipelines.py index ee9c14f..89fe3ee 100644 --- a/experiments/param-opti/src/qap/test_pipeline_config.py +++ b/experiments/param-opti/src/qap/test_conf_pipelines.py @@ -1,4 +1,5 @@ from typing import List, Dict, Any, Optional +import itertools import json import random from kgpipe.common import KgPipe, Data, DataFormat, Registry @@ -18,7 +19,9 @@ from param_opti.tasks.genie import genie_text_extraction_task from param_opti.tasks.spotlight import spotlight_entity_linking_task from param_opti.tasks.matching_helpers import aggregate_matching_results_task - +from param_opti.tasks.text_helpers import aggregate_entity_linking_task, aggregate_relation_linking_task +from param_opti.tasks.text_helpers import generate_rdf_from_text_results_task +from param_opti.tasks.select_lib import select_first_value_task from kgpipe.generation.loaders import build_from_conf from pathlib import Path # for given tasks and config parameters, generate a pipeline (KGpipe) @@ -30,6 +33,9 @@ RDF_SAMPLED_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "rdf_sampled_pipeline_configs.json" _RDF_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 +TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "text_sampled_pipeline_configs.json" +_TEXT_PIPELINE_CONFIG_SNAPSHOT_VERSION = 1 + class PipelineConfig(BaseModel): tasks: List[KgTask] @@ -38,17 +44,17 @@ class PipelineConfig(BaseModel): RDF_SEARCH_SPACE = { "graph_alignment_label_alias_embedding_transformer_task": { "category": ["ontology_matching", "entity_matching", "aggregate_matching_results"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, "relation_matcher_label_alias_embedding_transformer_task": { "category": ["ontology_matching"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, "entity_matcher_label_alias_embedding_transformer_task": { "category": ["entity_matching"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, "paris_ontology_matching_task": { @@ -73,12 +79,12 @@ class PipelineConfig(BaseModel): }, "relation_linker_label_alias_embedding_transformer_task": { "category": ["entity_linking"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, "entity_linker_label_alias_embedding_transformer_task": { "category": "entity_linking", - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, } @@ -98,15 +104,24 @@ class PipelineConfig(BaseModel): }, "relation_linker_label_alias_embedding_transformer_task": { "category": ["relation_linking"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, "entity_linker_label_alias_embedding_transformer_task": { "category": ["entity_linking"], - "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2"], + "model_name": ["sentence-transformers/all-MiniLM-L6-v2", "sentence-transformers/all-mpnet-base-v2", "infloat/e5-base-v2"], "similarity_threshold": [0.5, 0.6, 0.7, 0.8, 0.9], }, - "fusion_first_value_task": { + "aggregate_entity_linking_task": { + "category": ["aggregate_entity_linking"], + }, + "aggregate_relation_linking_task": { + "category": ["aggregate_relation_linking"], + }, + "generate_rdf_from_text_results_task": { + "category": ["construct_rdf"], + }, + "select_first_value_task": { "category": ["fusion"], }, } @@ -117,7 +132,10 @@ class PipelineConfig(BaseModel): "spotlight_entity_linking_task": spotlight_entity_linking_task, "relation_linker_label_alias_embedding_transformer_task": relation_linker_label_alias_embedding_transformer_task, "entity_linker_label_alias_embedding_transformer_task": entity_linker_label_alias_embedding_transformer_task, - "fusion_first_value_task": fusion_first_value_task, + "select_first_value_task": select_first_value_task, + "aggregate_entity_linking_task": aggregate_entity_linking_task, + "aggregate_relation_linking_task": aggregate_relation_linking_task, + "generate_rdf_from_text_results_task": generate_rdf_from_text_results_task, } RDF_TASK_DICT = { @@ -134,6 +152,8 @@ class PipelineConfig(BaseModel): # "fusion_union_task": fusion_union_task, } + + task_dict = {**TEXT_TASK_DICT, **RDF_TASK_DICT} for task_name, task in RDF_TASK_DICT.items(): @@ -145,6 +165,13 @@ class PipelineLayout(BaseModel): """ allowed_task_categories: List[str] +TEXT_PIPELINE_LAYOUT = PipelineLayout( + allowed_task_categories=["information_extraction", "entity_linking", "aggregate_entity_linking", "relation_linking", "aggregate_relation_linking", "construct_rdf", "fusion"] +) + +RDF_PIPELINE_LAYOUT = PipelineLayout( + allowed_task_categories=["ontology_matching", "entity_matching", "aggregate_matching_results", "fusion"] +) def _task_categories_list(search_space: Dict[str, Dict[str, Any]], task_name: str) -> List[str]: raw = search_space.get(task_name, {}).get("category") @@ -286,6 +313,17 @@ def load_rdf_sampled_pipeline_configs(path: Optional[Path] = None) -> List[Pipel return [pipeline_config_from_snapshot(item) for item in raw["samples"]] +def load_text_sampled_pipeline_configs(path: Optional[Path] = None) -> List[PipelineConfig]: + fixture_path = path or TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE + raw = json.loads(fixture_path.read_text(encoding="utf-8")) + if raw.get("version") != _TEXT_PIPELINE_CONFIG_SNAPSHOT_VERSION: + raise ValueError( + f"Unsupported text sampled configs snapshot version {raw.get('version')!r}; " + f"expected {_TEXT_PIPELINE_CONFIG_SNAPSHOT_VERSION}" + ) + return [pipeline_config_from_snapshot(item) for item in raw["samples"]] + + # TODO rules for valid pipeline config: def sample_valid_pipeline_config( search_space: Dict[str, Dict[str, Any]], @@ -432,6 +470,88 @@ def sample_config_catalog_for_task_combo( return PipelineConfig(tasks=tasks, config_catalog=config_catalog) +def enumerate_exhaustive_pipeline_config_snapshots( + search_space: Dict[str, Dict[str, Any]], + pipeline_layout: PipelineLayout, +) -> List[Dict[str, Any]]: + combos = enumerate_valid_task_combinations(search_space, pipeline_layout) + + def _task_param_assignments(task_key: str) -> List[Dict[str, Any]]: + space = search_space.get(task_key, {}) + param_space: Dict[str, List[Any]] = {k: v for k, v in space.items() if k != "category"} + if not param_space: + return [{}] + keys = list(param_space.keys()) + values_lists = [param_space[k] for k in keys] + return [dict(zip(keys, values)) for values in itertools.product(*values_lists)] + + all_snapshots: List[Dict[str, Any]] = [] + total_expected = 0 + + for combo in combos: + per_task_assignments = [_task_param_assignments(task_key) for task_key in combo] + + expected_for_combo = 1 + for assignments in per_task_assignments: + expected_for_combo *= len(assignments) + total_expected += expected_for_combo + + produced_for_combo = 0 + print() + print("combo:", combo) + print("expected configs:", expected_for_combo) + + for assignment_tuple in itertools.product(*per_task_assignments): + produced_for_combo += 1 + if produced_for_combo % 100 == 1 or produced_for_combo == expected_for_combo: + print(f"config {produced_for_combo}/{expected_for_combo}") + + tasks: List[KgTask] = [] + config_catalog: Dict[str, ConfigurationProfile] = {} + + for task_key, params in zip(combo, assignment_tuple): + task = task_dict[task_key] + tasks.append(task) + + if not params: + continue + if getattr(task, "config_spec", None) is None: + continue + + bindings: List[ParameterBinding] = [] + name_parts: List[str] = [] + + # Iterate in search_space order for stable snapshots. + for config_name, _config_values in search_space[task_key].items(): + if config_name == "category": + continue + if config_name not in params: + continue + config_value = params[config_name] + name_parts.append(f"{config_name}={config_value}") + bindings.append( + ParameterBinding( + parameter=_get_param(task.config_spec, config_name), + value=config_value, + ) + ) + + config_catalog[task.name] = ConfigurationProfile( + name=f"{task.name}_" + ",".join(name_parts), + definition=task.config_spec, + bindings=bindings, + ) + + pipeline_config = PipelineConfig(tasks=tasks, config_catalog=config_catalog) + all_snapshots.append(pipeline_config_to_snapshot(combo, pipeline_config)) + + assert produced_for_combo == expected_for_combo + + print() + print("TOTAL expected configs:", total_expected) + print("TOTAL generated snapshots:", len(all_snapshots)) + return all_snapshots + def test_sample_valid_rdf_pipeline_config(): @@ -443,10 +563,7 @@ def test_sample_valid_rdf_pipeline_config(): def test_enumerate_all_valid_rdf_task_combinations_no_config_sampling(): print("enumerate_all_valid_rdf_task_combinations_no_config_sampling") - pipeline_layout = PipelineLayout( - allowed_task_categories=["ontology_matching", "entity_matching", "aggregate_matching_results", "fusion"] - ) - combos = enumerate_valid_task_combinations(RDF_SEARCH_SPACE, pipeline_layout) + combos = enumerate_valid_task_combinations(RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT) for combo in combos: print(combo) @@ -461,17 +578,16 @@ def test_enumerate_all_valid_rdf_task_combinations_no_config_sampling(): # ("paris_graph_alignment_task", "fusion_first_value_task"), # } - # assert set(tuple(c) for c in combos) == expected + # assert set(tuple(c) for c in combos) == expected + + def test_enumerate_all_valid_rdf_task_combinations_with_config_sampling(): print("enumerate_all_valid_rdf_task_combinations_with_config_sampling") n = 1 rng = random.Random(0) - pipeline_layout = PipelineLayout( - allowed_task_categories=["ontology_matching", "entity_matching", "aggregate_matching_results", "fusion"] - ) - combos = enumerate_valid_task_combinations(RDF_SEARCH_SPACE, pipeline_layout) + combos = enumerate_valid_task_combinations(RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT) total_config_count = 0 snapshots: List[Dict[str, Any]] = [] @@ -502,33 +618,25 @@ def test_enumerate_all_valid_rdf_task_combinations_with_config_sampling(): def test_sample_valid_text_pipeline_config(): - pipeline_layout = PipelineLayout( - allowed_task_categories=["information_extraction", "entity_linking", "relation_linking", "fusion"] - ) - pipeline_config = sample_valid_pipeline_config(TEXT_SEARCH_SPACE, pipeline_layout) + pipeline_config = sample_valid_pipeline_config(TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT) print_pipeline_config_short(pipeline_config) def test_enumerate_all_valid_text_task_combinations_no_config_sampling(): print("enumerate_all_valid_text_task_combinations_no_config_sampling") - pipeline_layout = PipelineLayout( - allowed_task_categories=["information_extraction", "entity_linking", "relation_linking", "fusion"] - ) - combos = enumerate_valid_task_combinations(TEXT_SEARCH_SPACE, pipeline_layout) + combos = enumerate_valid_task_combinations(TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT) for combo in combos: print(combo) def test_enumerate_all_valid_text_task_combinations_with_config_sampling(): print("enumerate_all_valid_text_task_combinations_with_config_sampling") - n = 5 + n = 1 rng = random.Random(0) - pipeline_layout = PipelineLayout( - allowed_task_categories=["information_extraction", "entity_linking", "relation_linking", "fusion"] - ) - combos = enumerate_valid_task_combinations(TEXT_SEARCH_SPACE, pipeline_layout) + combos = enumerate_valid_task_combinations(TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT) total_config_count = 0 + snapshots: List[Dict[str, Any]] = [] for combo in combos: print() @@ -540,6 +648,35 @@ def test_enumerate_all_valid_text_task_combinations_with_config_sampling(): TEXT_SEARCH_SPACE, combo, rng=rng ) print_pipeline_config_short(pipeline_config) + snapshots.append(pipeline_config_to_snapshot(combo, pipeline_config)) + + TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE.parent.mkdir(parents=True, exist_ok=True) + TEXT_SAMPLED_PIPELINE_CONFIGS_FIXTURE.write_text( + json.dumps( + {"version": _TEXT_PIPELINE_CONFIG_SNAPSHOT_VERSION, "samples": snapshots}, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + +def test_enumerate_all_valid_text_task_combinations_with_config_sampling_exhaustive(): + print("enumerate_all_valid_text_task_combinations_with_config_sampling_exhaustive") + all_snapshots = enumerate_exhaustive_pipeline_config_snapshots( + TEXT_SEARCH_SPACE, TEXT_PIPELINE_LAYOUT + ) + serialized = [json.dumps(s, sort_keys=True) for s in all_snapshots] + assert len(set(serialized)) == len(serialized) + + +def test_enumerate_all_valid_rdf_task_combinations_with_config_sampling_exhaustive(): + print("enumerate_all_valid_rdf_task_combinations_with_config_sampling_exhaustive") + all_snapshots = enumerate_exhaustive_pipeline_config_snapshots( + RDF_SEARCH_SPACE, RDF_PIPELINE_LAYOUT + ) + serialized = [json.dumps(s, sort_keys=True) for s in all_snapshots] + assert len(set(serialized)) == len(serialized) diff --git a/experiments/param-opti/src/qap/test_eval_pipelines.py b/experiments/param-opti/src/qap/test_eval_pipelines.py new file mode 100644 index 0000000..bcc5012 --- /dev/null +++ b/experiments/param-opti/src/qap/test_eval_pipelines.py @@ -0,0 +1,122 @@ +from pathlib import Path +import json +import pytest + +from kgpipe_eval.utils.kg_utils import KgManager +from kgpipe_eval.metrics.triple_alignment import TripleAlignmentMetric, TripleAlignmentConfig +from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric, EntityAlignmentConfig +from kgpipe_eval.api import MetricResult +from kgpipe_eval.test.utils import render_metric_result + +rdf_base_dir = Path("data/tmp/rdf_pipelines/") +text_base_dir = Path("data/tmp/text_pipelines/") +result_dir = Path("data/output/reference_eval") + +def get_rdf_final_kgs(): + """ + get all files matching rdf_result_saved_sample_config_idx_*.nt in dir + """ + BASE_DIR = rdf_base_dir + return [f for f in BASE_DIR.glob("*eval.nt")] + +def get_text_final_kgs(): + """ + get all files matching text_result_saved_sample_config_idx_*.nt in dir + """ + BASE_DIR = text_base_dir + return [f for f in BASE_DIR.glob("*eval.nt")] + +def test_get_rdf_final_kgs(): + """ + test the get_final_kgs function + """ + final_kgs = get_rdf_final_kgs() + for final_kg in final_kgs: + print(final_kg) + +def test_get_text_final_kgs(): + """ + test the get_final_kgs function + """ + final_kgs = get_text_final_kgs() + for final_kg in final_kgs: + print(final_kg) + +def _write_to_file(string: str, path: Path): + with open(path, "w") as f: + f.write(string) + print(f"wrote to {path}") + +def _metric_result_to_jsonable(metric_result: MetricResult) -> dict: + metric = metric_result.metric + metric_key = getattr(metric, "key", metric.__class__.__name__) + return { + "metric": metric_key, + "summary": metric_result.summary, + "measurements": [ + {"name": m.name, "value": m.value, "unit": m.unit} + for m in metric_result.measurements + ], + } + +def _write_json(obj: object, path: Path): + with open(path, "w") as f: + json.dump(obj, f, indent=2, sort_keys=True, default=str) + f.write("\n") + print(f"wrote to {path}") + +# seed_kg = KgManager.load_kg(Path("data/input_final/target_kg/graph.nt")) + +def eval_pipeline(final_kg, reference_kg_path): + + print(f"evaluating {final_kg}") + + ref_kg_path = reference_kg_path + gen_kg_path = final_kg + + entity_alignment_config = EntityAlignmentConfig( + method="label_embedding", + reference_kg=ref_kg_path, + verified_entities_path=None, + verified_entities_delimiter="\t", + entity_sim_threshold=0.95 + ) + + + gen_kg = KgManager.load_kg(gen_kg_path) + # test_kg = KgManager.substract_kg(gen_kg, seed_kg) # TODO add back labels and types + test_kg = gen_kg + + metric_result : MetricResult = EntityAlignmentMetric().compute(test_kg, entity_alignment_config) + result_string = render_metric_result(metric_result) + _write_to_file(result_string, result_dir / (final_kg.name + ".entity_alignment.txt")) + _write_json(_metric_result_to_jsonable(metric_result), result_dir / (final_kg.name + ".entity_alignment.json")) + + + triple_alignment_config = TripleAlignmentConfig( + reference_kg=ref_kg_path, + entity_alignment_config=entity_alignment_config, + value_sim_threshold=0.5, + cache_literal_embeddings=True + ) + + metric_result : MetricResult = TripleAlignmentMetric().compute(test_kg, triple_alignment_config) + result_string = render_metric_result(metric_result) + _write_to_file(result_string, result_dir / (final_kg.name + ".triple_alignment.txt")) + _write_json(_metric_result_to_jsonable(metric_result), result_dir / (final_kg.name + ".triple_alignment.json")) + +@pytest.mark.parametrize("final_kg", get_rdf_final_kgs()) +def test_eval_rdf_pipeline_runs(final_kg): + """ + evaluate all runs of the rdf pipelines + """ + eval_pipeline(final_kg, Path("data/input_final/reference_kg/data_no_seed.nt")) + +@pytest.mark.parametrize("final_kg", get_text_final_kgs()) +def test_eval_text_pipeline_runs(final_kg): + """ + evaluate all runs of the text pipelines + """ + # data/input_final/txt_source/ref + + eval_pipeline(final_kg, Path("/data/datasets/params_experiments/latest/input_final/txt_source/tmp_reference/reference_kg_noseed.nt")) \ No newline at end of file diff --git a/experiments/param-opti/src/qap/test_exec_pipelines.py b/experiments/param-opti/src/qap/test_exec_pipelines.py index 2ae5626..1804e86 100644 --- a/experiments/param-opti/src/qap/test_exec_pipelines.py +++ b/experiments/param-opti/src/qap/test_exec_pipelines.py @@ -8,10 +8,11 @@ from param_opti.tasks.spotlight import spotlight_entity_linking_task from param_opti.tasks.text_helpers import aggregate_text_tasks_task, generate_rdf_from_text_results_task from param_opti.tasks.select_lib import select_first_value_task -from qap.test_pipeline_config import ( +from qap.test_conf_pipelines import ( PipelineConfig, _get_param, load_rdf_sampled_pipeline_configs, + load_text_sampled_pipeline_configs, ) from pathlib import Path import pytest @@ -20,7 +21,7 @@ from dotenv import load_dotenv load_dotenv() -tmp_base_dir = Path("tmp") +tmp_base_dir = Path("data/tmp/text_pipelines") if not tmp_base_dir.exists(): tmp_base_dir.mkdir(parents=True, exist_ok=True) @@ -84,15 +85,12 @@ def test_rdf_pipeline_from_saved_sampled_configs(config_idx): pipeline_config = configs[config_idx] - seed_path = tmp_base_dir / "seed_saved_sample.nt" - source_path = tmp_base_dir / "source_saved_sample.nt" - result_path = tmp_base_dir / f"result_saved_sample_config_idx_{config_idx}.nt" - tasks_tmp_dir = tmp_base_dir / f"tasks_tmp_saved_sample_config_idx_{config_idx}" + seed_path = Path("data/input_final/target_kg/graph.nt") + source_path = Path("data/input_final/rdf_source/graph.nt") + result_path = tmp_base_dir / f"rdf_result_saved_sample_config_idx_{config_idx}.nt" + tasks_tmp_dir = tmp_base_dir / f"rdf_tasks_tmp_saved_sample_config_idx_{config_idx}" tasks_tmp_dir.mkdir(parents=True, exist_ok=True) - seed_path.write_text(" .\n") - source_path.write_text(" .\n") - pipeline = KgPipe( tasks=pipeline_config.tasks, seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), @@ -110,7 +108,6 @@ def test_rdf_pipeline_from_saved_sampled_configs(config_idx): pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=True) - def get_default_text_pipeline_config() -> PipelineConfig: return PipelineConfig( tasks=[ @@ -168,3 +165,38 @@ def test_text_pipeline_from_default_config(): pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=False) +@pytest.mark.parametrize("config_idx", range(len(load_text_sampled_pipeline_configs()))) +def test_text_pipeline_from_saved_sampled_configs(config_idx): + """Runs KGpipe using PipelineConfigs materialized from the JSON fixture written by test_pipeline_config.""" + configs = load_text_sampled_pipeline_configs() + assert configs, "fixtures/text_sampled_pipeline_configs.json is missing or empty; run test_enumerate_all_valid_text_task_combinations_with_config_sampling" + + pipeline_config = configs[config_idx] + + seed_path = Path("data/input_final/target_kg/graph.nt") + source_path = Path("data/input_final/txt_source/docs") + result_path = tmp_base_dir / f"text_result_saved_sample_config_idx_{config_idx}.nt" + tasks_tmp_dir = tmp_base_dir / f"text_tasks_tmp_saved_sample_config_idx_{config_idx}" + tasks_tmp_dir.mkdir(parents=True, exist_ok=True) + + pipeline = KgPipe( + tasks=pipeline_config.tasks, + seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), + data_dir=tasks_tmp_dir, + name="test_text_pipeline_saved_sample", + ) + + print(f"Building pipeline... {config_idx}") + print("#######################") + + pipeline.build( + stable_files=True, + configCatalog=pipeline_config.config_catalog, + source=Data(path=source_path, format=DataFormat.TEXT), + result=Data(path=result_path, format=DataFormat.RDF_NTRIPLES), + ) + + print(f"Running pipeline... {config_idx}") + print("#######################") + + pipeline.run(configCatalog=pipeline_config.config_catalog, stable_files_override=False) \ No newline at end of file diff --git a/src/kgpipe_eval/utils/alignment_utils.py b/src/kgpipe_eval/utils/alignment_utils.py index 1e4f16c..0f9359d 100644 --- a/src/kgpipe_eval/utils/alignment_utils.py +++ b/src/kgpipe_eval/utils/alignment_utils.py @@ -220,13 +220,26 @@ def _build_bnode_map(gen_g: TripleGraph, ref_g: TripleGraph) -> dict[str, Term]: if DEBUG: print("Entity alignments: ", len(entity_alignments)) + def _as_term(t: Term | str) -> Term: + # Entity alignment currently carries string IDs; convert to rdflib Terms so + # aligned triples are comparable to `ref_tg.triples(...)` output. + if isinstance(t, str): + try: + from rdflib import URIRef + return URIRef(t) + except Exception: + # Fall back to raw string (will likely not match ref triples, but + # avoids crashing on non-URI identifiers). + return t # type: ignore[return-value] + return t + gen_to_ref_entity: dict[str, Term] = {} best_score_by_gen: dict[str, float] = {} for a in entity_alignments: gen_key = str(a.source) if gen_key not in best_score_by_gen or a.score > best_score_by_gen[gen_key]: best_score_by_gen[gen_key] = float(a.score) - gen_to_ref_entity[gen_key] = a.target + gen_to_ref_entity[gen_key] = _as_term(a.target) # 2) Index generated triples, both raw and entity-mapped mapped_gen_triples: list[tuple[Triple, Triple]] = [] # (raw_gen, mapped_to_ref_space) @@ -308,13 +321,10 @@ def get_gen_literal_embedding(texts: list[str]) -> np.ndarray: if DEBUG: print("gen_lit_emb_by_text: ", len(gen_lit_emb_by_text)) if DEBUG: print("ref_lit_emb_by_text: ", len(ref_lit_emb_by_text)) - from rdflib import Graph, URIRef + from rdflib import Graph gen_graph : Graph = tg._graph() ref_graph : Graph = ref_tg._graph() - test_objects = list(ref_graph.objects(URIRef("http://kg.org/resource/f4eb17c4ed78c87c29124018c9f180b5"), URIRef("http://kg.org/ontology/deathPlace"), unique=True)) - if DEBUG: print("Test objects: ", len(test_objects)) - if DEBUG: print("Gen graph: ", len(list(gen_graph.triples((None, None, None))))) if DEBUG: print("Ref graph: ", len(list(ref_graph.triples((None, None, None))))) @@ -330,7 +340,7 @@ def get_gen_literal_embedding(texts: list[str]) -> np.ndarray: gen_literal_objs = [o for o in gen_objects if _is_literal(o)] # IMPORTANT: query reference objects in reference-space subject - ref_objects = list(ref_graph.objects(URIRef(str(ref_s)), URIRef(str(gp)))) + ref_objects = list(ref_graph.objects(ref_s, gp)) ref_literal_objs = [o for o in ref_objects if _is_literal(o)] # print("gs: ", gs, "gp: ", gp) @@ -366,7 +376,13 @@ def get_gen_literal_embedding(texts: list[str]) -> np.ndarray: # find if any of the non-literal objects in the generated graph are mapped to the same object in the reference graph for gen_obj in gen_object_non_literal: - if gen_to_ref_entity.get(str(gen_obj), gen_bnode_to_ref.get(str(gen_obj), gen_obj)) in ref_object_non_literal: - alignments.append(TripleAlignment(source=(gs, gp, gen_obj), target=(gs, gp, ref_object_non_literal[ref_object_non_literal.index(gen_obj)]))) + ref_o = gen_to_ref_entity.get(str(gen_obj), gen_bnode_to_ref.get(str(gen_obj), gen_obj)) + if ref_o in ref_object_non_literal: + alignments.append( + TripleAlignment( + source=(gs, gp, gen_obj), + target=(ref_s, gp, ref_o), + ) + ) return alignments \ No newline at end of file From 6dc3e4083a8ba42da09064002a528e970559c391 Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 13 May 2026 12:14:41 +0200 Subject: [PATCH 33/42] fix(eval): renamed triple align metric --- src/kgpipe_eval/metrics/triple_alignment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/kgpipe_eval/metrics/triple_alignment.py b/src/kgpipe_eval/metrics/triple_alignment.py index 516f18d..5114ddb 100644 --- a/src/kgpipe_eval/metrics/triple_alignment.py +++ b/src/kgpipe_eval/metrics/triple_alignment.py @@ -51,7 +51,7 @@ def eval_triple_alignment(tg: TripleGraph, config: TripleAlignmentConfig): # def eval_triple_alignment_by_label_embedding_soft_literals(method: Literal["exact", "fuzzy", "semantic"] = "exact"): # pass -class ReferenceTripleAlignmentMetric(Metric): +class TripleAlignmentMetric(Metric): def compute(self, kg: KG, config: TripleAlignmentConfig): m: BCMeasurement = eval_triple_alignment(kg, config) @@ -71,4 +71,4 @@ def compute(self, kg: KG, config: TripleAlignmentConfig): # Backward-compatibility alias (imported by `kgpipe_eval.metrics.__init__`). -TripleAlignmentMetric = ReferenceTripleAlignmentMetric \ No newline at end of file +# TripleAlignmentMetric = TripleAlignmentMetric \ No newline at end of file From 9987cb2a7a417c9222ecfb8eee12d5b5d43e479e Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 13 May 2026 12:15:06 +0200 Subject: [PATCH 34/42] exp(moviekg): changed paths --- .../moviekg/evaluation/test_eval_refactor.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py index 1dacc8f..61621cb 100644 --- a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py +++ b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py @@ -4,7 +4,7 @@ from kgpipe_eval.metrics.statistics import CountMetric from kgpipe_eval.metrics.duplicates import DuplicateConfig, DuplicateMetric from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric -from kgpipe_eval.metrics.triple_alignment import TripleAlignmentConfig, ReferenceTripleAlignmentMetric +from kgpipe_eval.metrics.triple_alignment import TripleAlignmentConfig, TripleAlignmentMetric from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig from kgpipe_eval.utils.kg_utils import KgLike, KgManager from kgpipe_eval.evaluator import Evaluator @@ -79,7 +79,7 @@ def from_path(path: Path | str) -> 'KgPipeData': report = KgPipeReport.from_path(path / "exec-report.json") tmp_dir = path / "tmp" return KgPipeData( - result_kg=KG(name=path.name, id=path.name, path=path / "result.nt", format=DataFormat.RDF_NTRIPLES), + result_kg=KG(name=path.name, id=path.name, path=path / "result_eval.nt", format=DataFormat.RDF_NTRIPLES), plan=plan, report=report, tmp_dir=tmp_dir @@ -96,14 +96,17 @@ def build_config_dict(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> ) tri_cfg = TripleAlignmentConfig( - reference_kg=bench_data.dataset.splits[f"split_{i}"].kg_reference, + reference_kg=bench_data.dataset.splits[f"split_{i}"].kg_reference.root / "data_agg_eval.nt", entity_alignment_config=EntityAlignmentConfig( method="label_embedding", - verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data - verified_entities_delimiter="\t", + reference_kg=bench_data.dataset.splits[f"split_{i}"].kg_reference.root / "data_agg_eval.nt", + # verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data + # verified_entities_delimiter="\t", entity_sim_threshold=0.95, ), value_sim_threshold=0.5, + cache_literal_embeddings=True, + cache_ref_literal_embeddings=True, ) ent_cfg = EntityAlignmentConfig( @@ -127,7 +130,7 @@ def evaluate_stage(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> Li CountMetric(), EntityAlignmentMetric(), DuplicateMetric(), - ReferenceTripleAlignmentMetric(), + TripleAlignmentMetric(), ] config_dict = build_config_dict(i, pipe_data, bench_data) return Evaluator().run(tg, metrics, config_dict) @@ -208,6 +211,8 @@ def test_evaluate_new(pipeline_name: str): for stage_dir in stage_dirs: i = int(stage_dir.name.split("_", 1)[1]) + if i != 3: + continue # only run for stage 3 pipe_data = KgPipeData.from_path(stage_dir) results = evaluate_stage(i=i, pipe_data=pipe_data, bench_data=bench_data) @@ -245,6 +250,8 @@ def test_evaluate_new_multisource_pipeline(source_1: str, source_2: str, source_ for stage_dir in stage_dirs: i = int(stage_dir.name.split("_", 1)[1]) + if i != 3: + continue # only run for stage 3 pipe_data = KgPipeData.from_path(stage_dir) results = evaluate_stage(i=i, pipe_data=pipe_data, bench_data=bench_data) From 36e0489709a2b80a6aef3f5fa72660e63c41ca07 Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 13 May 2026 12:21:31 +0200 Subject: [PATCH 35/42] feat(genie-task) --- .../param-opti/wrappers/genie/Dockerfile | 30 +++++ .../param-opti/wrappers/genie/README.md | 79 +++++++++++ .../wrappers/genie/bin/genie_cli.py | 126 ++++++++++++++++++ .../param-opti/wrappers/genie/genie.sh | 43 ++++++ .../param-opti/wrappers/genie/output.json | 20 +++ .../param-opti/wrappers/genie/output_1.json | 26 ++++ .../param-opti/wrappers/genie/test.txt | 3 + .../param-opti/wrappers/genie/test_1.txt | 1 + .../wrappers/genie/test_docker_run.sh | 1 + 9 files changed, 329 insertions(+) create mode 100644 experiments/param-opti/wrappers/genie/Dockerfile create mode 100644 experiments/param-opti/wrappers/genie/README.md create mode 100644 experiments/param-opti/wrappers/genie/bin/genie_cli.py create mode 100644 experiments/param-opti/wrappers/genie/genie.sh create mode 100644 experiments/param-opti/wrappers/genie/output.json create mode 100644 experiments/param-opti/wrappers/genie/output_1.json create mode 100644 experiments/param-opti/wrappers/genie/test.txt create mode 100644 experiments/param-opti/wrappers/genie/test_1.txt create mode 100644 experiments/param-opti/wrappers/genie/test_docker_run.sh diff --git a/experiments/param-opti/wrappers/genie/Dockerfile b/experiments/param-opti/wrappers/genie/Dockerfile new file mode 100644 index 0000000..a66625e --- /dev/null +++ b/experiments/param-opti/wrappers/genie/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.8-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git wget unzip\ + && rm -rf /var/lib/apt/lists/* + +RUN git clone https://github.com/epfl-dlab/GenIE.git + +WORKDIR /app/GenIE + +RUN pip install --upgrade pip + +RUN pip install -r pip_requirements.txt + +RUN mkdir -p data/models + +# Models initialized with a pretrained language model (GenIE - PLM) Trained on Rebel +RUN wget https://zenodo.org/record/6139236/files/genie_plm_r.ckpt \ + -O data/models/genie_plm_r.ckpt + +RUN wget https://zenodo.org/record/6139236/files/tries.zip \ + && unzip tries.zip -d data && rm tries.zip + +COPY bin/genie_cli.py /app/GenIE/genie_cli.py +COPY genie.sh /usr/local/bin/genie.sh +RUN chmod +x /usr/local/bin/genie.sh + + diff --git a/experiments/param-opti/wrappers/genie/README.md b/experiments/param-opti/wrappers/genie/README.md new file mode 100644 index 0000000..4b21480 --- /dev/null +++ b/experiments/param-opti/wrappers/genie/README.md @@ -0,0 +1,79 @@ +# README.md +## Build Docker +```bash +docker build -t genie . +``` + +## Run Docker +```bash +docker run --rm \ + -v /home/theo/Work/SCADS.AI/Projects/KGpipe/experiments/text-pipelines/test/Titanic.txt:/data/input.txt \ + -v /home/theo/Work/SCADS.AI/Projects/KGpipe/experiments/text-pipelines/wrappers/genie/output.json:/data/output.json \ + genie genie.sh /data/input.txt /data/output.json +``` + + +## Tool Parameters + +### Model Parameters +- `checkpoint` (pre trained model) **or** +- `hydra` + +--- + +### Constraint Parameters +- `entity_trie` (pickle) **or** string list +- `relation_trie` (pickle) **or** string list + +--- + +### Generate Parameters +Uses standard `Transformers generate()` function. + +#### Beam Search +- `num_beams` +- `num_return_sequences` +- `early_stopping` +- `length_penalty` + +#### Sampling +- `do_sample` +- `temperature` +- `top_k` +- `top_p` +- `typical_p` + +#### Output Length +- `max_length` +- `max_new_tokens` +- `min_length` +- `min_new_tokens` + +#### Scores & Debug +- `return_dict_in_generate` +- `output_scores` +- `output_attentions` +- `output_hidden_states` +- `output_logits` + +#### Seed +- `seed` + +#### Token-Control +- `bos_token_id` +- `eos_token_id` +- `pad_token_id` +- `decoder_start_token_id` +- `forced_bos_token_id` +- `forced_eos_token_id` + +#### Repetition / Constraints +- `repetition_penalty` +- `no_repeat_ngram_size` +- `bad_words_ids` +- `force_words_ids` +- `constraints` +- `prefix_allowed_tokens_fn` + + + diff --git a/experiments/param-opti/wrappers/genie/bin/genie_cli.py b/experiments/param-opti/wrappers/genie/bin/genie_cli.py new file mode 100644 index 0000000..3382e36 --- /dev/null +++ b/experiments/param-opti/wrappers/genie/bin/genie_cli.py @@ -0,0 +1,126 @@ +import sys +import os +import json +import re + +from genie.models import GeniePL +from genie.constrained_generation import Trie + +DATA_DIR = os.path.join(os.getcwd(), "data") + + +def load_model(): + ckpt_name = "genie_plm_r.ckpt" + path_to_checkpoint = os.path.join(DATA_DIR, "models", ckpt_name) + + model = GeniePL.load_from_checkpoint( + checkpoint_path=path_to_checkpoint + ) + + return model + + +def load_tries(): + entity_trie_path = os.path.join(DATA_DIR, "tries/large/entity_trie.pickle") + entity_trie = Trie.load(entity_trie_path) + + relation_trie_path = os.path.join(DATA_DIR, "tries/large/relation_trie.pickle") + relation_trie = Trie.load(relation_trie_path) + + return {"entity_trie": entity_trie, "relation_trie": relation_trie} + + +def split_into_sentences(text: str): + text = re.sub(r"\s+", " ", text).strip() + if not text: + return [] + + # Keep this lightweight so folder mode still benefits from a + # single long-lived Python process without extra tokenizer deps. + parts = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9\"'(\[])", + text) + return [part.strip() for part in parts if part.strip()] + + +def extract_file(model, tries, input_path: str, output_path: str): + with open(input_path, "r", encoding="utf-8") as f: + text = f.read() + + sentences = split_into_sentences(text) + if not sentences: + with open(output_path, "w", encoding="utf-8") as f: + json.dump([], f, indent=2, ensure_ascii=False) + return + + generation_args = { + "num_beams": 5, + "num_return_sequences": 1, + "max_length": 128, + "early_stopping": True, + "no_repeat_ngram_size": 3, + "repetition_penalty": 1.2, + "length_penalty": 0.8, + "return_dict_in_generate": True, + "output_scores": True, + } + + outputs = model.sample( + sentences, + **tries, + **generation_args, + ) + + with open(output_path, "w", encoding="utf-8") as f: + json.dump(outputs, f, indent=2, ensure_ascii=False) + + +def main(): + if len(sys.argv) < 3: + print("Usage: genie.sh ") + sys.exit(1) + + input_path = sys.argv[1] + output_path = sys.argv[2] + + if os.path.isdir(input_path): + if os.path.isfile(output_path): + raise SystemExit("Error: output must be a folder when input is a folder") + + os.makedirs(output_path, exist_ok=True) + + model = load_model() + tries = load_tries() + + files = [ + os.path.join(input_path, name) + for name in os.listdir(input_path) + if os.path.isfile(os.path.join(input_path, name)) + ] + files.sort() + + for in_file in files: + filename = os.path.basename(in_file) + out_file = os.path.join(output_path, filename) + if os.path.exists(out_file): + continue + extract_file(model, tries, in_file, out_file) + print(f"Processed {in_file} → {out_file}") + + print(f"Extraction finished. Results written to folder {output_path}") + return + + if os.path.isfile(input_path): + if os.path.isdir(output_path): + raise SystemExit("Error: output must be a file when input is a file") + + model = load_model() + tries = load_tries() + extract_file(model, tries, input_path, output_path) + print(f"Extraction finished. Results written to {output_path}") + return + + raise SystemExit("Error: input must be a file or directory") + + +if __name__ == "__main__": + main() diff --git a/experiments/param-opti/wrappers/genie/genie.sh b/experiments/param-opti/wrappers/genie/genie.sh new file mode 100644 index 0000000..71fb229 --- /dev/null +++ b/experiments/param-opti/wrappers/genie/genie.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -e + +if [ "$#" -ne 2 ]; then + echo "Usage:" + echo " genie.sh " + echo " genie.sh " + exit 1 +fi + +INPUT="$1" +OUTPUT="$2" + +GRAPHENE_DIR="/app/GenIE" + +if [ -f "$INPUT" ]; then + if [ -d "$OUTPUT" ]; then + echo "Error: Output must be a file when input is a file" + exit 1 + fi + + echo "Processing single file..." + python /app/GenIE/genie_cli.py "$INPUT" "$OUTPUT" + echo "Done." + exit 0 +fi + +if [ -d "$INPUT" ]; then + if [ -f "$OUTPUT" ]; then + echo "Error: Output must be a folder when input is a folder" + exit 1 + fi + mkdir -p "$OUTPUT" + chmod 777 "$OUTPUT" + + echo "Processing folder..." + python /app/GenIE/genie_cli.py "$INPUT" "$OUTPUT" + echo "All files processed." + exit 0 +fi + +echo "Error: Input must be a file or directory" +exit 1 \ No newline at end of file diff --git a/experiments/param-opti/wrappers/genie/output.json b/experiments/param-opti/wrappers/genie/output.json new file mode 100644 index 0000000..aa99d3e --- /dev/null +++ b/experiments/param-opti/wrappers/genie/output.json @@ -0,0 +1,20 @@ +[ + [ + { + "text": " Captain America publisher Marvel Comics ", + "log_prob": -0.8582733273506165 + } + ], + [ + { + "text": " Marvel Studios parent organization Paramount Pictures ", + "log_prob": -0.5044617652893066 + } + ], + [ + { + "text": " El Capitan Theatre country United States ", + "log_prob": -0.5770944952964783 + } + ] +] \ No newline at end of file diff --git a/experiments/param-opti/wrappers/genie/output_1.json b/experiments/param-opti/wrappers/genie/output_1.json new file mode 100644 index 0000000..4972807 --- /dev/null +++ b/experiments/param-opti/wrappers/genie/output_1.json @@ -0,0 +1,26 @@ +[ + [ + { + "text": " Captain America publisher Marvel Comics ", + "log_prob": -0.4791736900806427 + } + ], + [ + { + "text": " Marvel Cinematic Universe production company Marvel Studios ", + "log_prob": -0.33403322100639343 + } + ], + [ + { + "text": " Captain America performer Chris Evans (actor) ", + "log_prob": -0.46612370014190674 + } + ], + [ + { + "text": " Captain America conflict World War II ", + "log_prob": -0.39299872517585754 + } + ] +] \ No newline at end of file diff --git a/experiments/param-opti/wrappers/genie/test.txt b/experiments/param-opti/wrappers/genie/test.txt new file mode 100644 index 0000000..4eb32be --- /dev/null +++ b/experiments/param-opti/wrappers/genie/test.txt @@ -0,0 +1,3 @@ +Captain America: The First Avenger is a 2011 American superhero film based on the Marvel Comics character Captain America. Produced by Marvel Studios and distributed by Paramount Pictures, it is the fifth film in the Marvel Cinematic Universe (MCU). The film was directed by Joe Johnston, written by Christopher Markus and Stephen McFeely, and stars Chris Evans as Steve Rogers / Captain America alongside Tommy Lee Jones, Hugo Weaving, Hayley Atwell, Sebastian Stan, Dominic Cooper, Toby Jones, Neal McDonough, Derek Luke, and Stanley Tucci. During World War II, Rogers, a frail man, is transformed into the super-soldier Captain America and must stop the Red Skull (Weaving) from using the Tesseract as an energy source for world domination. +The film began as a concept in 1997 and was scheduled for distribution by Artisan Entertainment. However, a lawsuit disrupted the project and was not settled until September 2003. In 2005, Marvel Studios received a loan from Merrill Lynch, and planned to finance and release the film through Paramount Pictures. Directors Jon Favreau and Louis Leterrier were interested in directing the project before Johnston was approached in 2008. The principal characters were cast between March and June 2010. Production began in June, and filming took place in London, Manchester, Caerwent, Liverpool, and Los Angeles. Several different techniques were used by the visual effects company Lola to create the physical appearance of the character before he becomes Captain America. +Captain America: The First Avenger premiered at the El Capitan Theatre in Los Angeles on July 19, 2011, and was released in the United States on July 22, as part of Phase One of the MCU. The film was commercially successful, grossing over $370 million worldwide, and received positive reviews from critics, who praised Evans' performance, the film's depiction of its 1940s time period, and Johnston's direction. Two direct sequels have been released: Captain America: The Winter Soldier (2014) and Captain America: Civil War (2016). diff --git a/experiments/param-opti/wrappers/genie/test_1.txt b/experiments/param-opti/wrappers/genie/test_1.txt new file mode 100644 index 0000000..ea6f33e --- /dev/null +++ b/experiments/param-opti/wrappers/genie/test_1.txt @@ -0,0 +1 @@ +Captain America: The First Avenger is a 2011 American superhero film based on the Marvel Comics character Captain America. Produced by Marvel Studios and distributed by Paramount Pictures, it is the fifth film in the Marvel Cinematic Universe (MCU). The film was directed by Joe Johnston, written by Christopher Markus and Stephen McFeely, and stars Chris Evans as Steve Rogers / Captain America alongside Tommy Lee Jones, Hugo Weaving, Hayley Atwell, Sebastian Stan, Dominic Cooper, Toby Jones, Neal McDonough, Derek Luke, and Stanley Tucci. During World War II, Rogers, a frail man, is transformed into the super-soldier Captain America and must stop the Red Skull (Weaving) from using the Tesseract as an energy source for world domination. diff --git a/experiments/param-opti/wrappers/genie/test_docker_run.sh b/experiments/param-opti/wrappers/genie/test_docker_run.sh new file mode 100644 index 0000000..9b00275 --- /dev/null +++ b/experiments/param-opti/wrappers/genie/test_docker_run.sh @@ -0,0 +1 @@ +docker run -v $(pwd):$(pwd) genie genie.sh $(pwd)/test_1.txt $(pwd)/output_1.json \ No newline at end of file From a32377f00bcf4e74c7b1c90cc6e2e7601008bb4d Mon Sep 17 00:00:00 2001 From: Marvin Date: Mon, 1 Jun 2026 14:02:29 +0200 Subject: [PATCH 36/42] exp(params): ignore test data, READE note to run sge pipelines --- experiments/param-opti/.gitignore | 6 +++++- experiments/param-opti/README.md | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/experiments/param-opti/.gitignore b/experiments/param-opti/.gitignore index a4d684a..a5521ea 100644 --- a/experiments/param-opti/.gitignore +++ b/experiments/param-opti/.gitignore @@ -1,3 +1,7 @@ output/ repos/ -output_qap_mock/ \ No newline at end of file +output_qap_mock/ +testdata/ +tmp/ +data/ +data \ No newline at end of file diff --git a/experiments/param-opti/README.md b/experiments/param-opti/README.md index 91e2f0f..edc5def 100644 --- a/experiments/param-opti/README.md +++ b/experiments/param-opti/README.md @@ -141,4 +141,9 @@ A `_summary.json` file is also generated with aggregate statistics. 1. Task Assignment: Selecting 2. Task Tunning -3. \ No newline at end of file +3. + + +# Notes + +../../.venv/bin/pytest -s --show-capture=no src/qap/test_exec_pipelines.py -k "test_rdf_pipeline_from_saved_sampled_configs" \ No newline at end of file From 5a5b022f02a9ed27793fe7b16229f4e4818c186d Mon Sep 17 00:00:00 2001 From: Marvin Date: Tue, 2 Jun 2026 13:38:50 +0200 Subject: [PATCH 37/42] docu: changed docs to mkdocs and workflow --- docs/index.md | 41 +++++- docs/metrics/metrics.md | 0 docs/metrics/reference_entity_alignment.md | 0 docs/metrics/reference_triple_alignment.md | 0 docs/metrics/stats_counts.md | 0 docs/migration.md | 0 docs/quickstart.md | 160 +++++++++++++++++++++ docs/reproduce.md | 2 +- mkdocs.yml | 52 +++++++ pyproject.toml | 4 + 10 files changed, 252 insertions(+), 7 deletions(-) create mode 100644 docs/metrics/metrics.md create mode 100644 docs/metrics/reference_entity_alignment.md create mode 100644 docs/metrics/reference_triple_alignment.md create mode 100644 docs/metrics/stats_counts.md create mode 100644 docs/migration.md create mode 100644 docs/quickstart.md create mode 100644 mkdocs.yml diff --git a/docs/index.md b/docs/index.md index 1a39c1b..8d9032a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,37 @@ KGpipe is a framework to define pipelines for data integration into knowledge gr The framework is organized into three main subpackages: `kgpipe` contains the core framework functionality including CLI, common utilities, execution backends, and evaluation components. `kgpipe_tasks` provides task implementations for cleaning, construction, entity resolution, schema alignment, and text processing. `kgpipe_llm` includes LLM-based task implementations and utilities. +**Current version**: 0.7.0 +**Python**: >= 3.12 + +## Quickstart + +Start here: [Quickstart guide](quickstart.md) + +Minimal “happy path” (install + discover + inspect what’s available): + +```bash +pip install -e . +kgpipe discover --all --show-results +kgpipe list --type tasks +kgpipe list --type metrics +``` + +Create a new experiment project (recommended): + +```bash +cd experiments/examples +./init.sh +``` + +## How to use KGpipe (docs map) + +- Define tasks: [Task specification](tasks.md) +- Build and run pipelines: [Pipelines](pipelines.md) +- Configure runs and task parameters: [Configuration](configuration.md) and [Parameters](parameters.md) +- Evaluate generated KGs: [Evaluation](evaluation.md) and [Metrics](metrics/) +- Understand the internal “PipeKG”: [Meta KG](metakg.md) + ## Meta KG [link](metakg.md) @@ -39,11 +70,9 @@ Additional evaluation metrics are documented in the [metrics](metrics/) director ## Other Links - [Reproducing the movie kg experiments for 15 pipelines](reproduce.md) (rdf, json, text) +- [Migration notes](migration.md) +- [UI / viewer](view.md) -## Docu Backlog +## Docs backlog -- Explain different execution modes - - File Batches - - Streaming -- Explain advanced pipelines -- Ontology creation... \ No newline at end of file +Open items live in `TODO.md` (High/Medium/Low priority). Keep the landing page focused on user-facing docs. \ No newline at end of file diff --git a/docs/metrics/metrics.md b/docs/metrics/metrics.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/metrics/reference_entity_alignment.md b/docs/metrics/reference_entity_alignment.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/metrics/reference_triple_alignment.md b/docs/metrics/reference_triple_alignment.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/metrics/stats_counts.md b/docs/metrics/stats_counts.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..b50f1ec --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,160 @@ +# KGpipe Quickstart + +This quickstart shows the **current** workflow for defining and running: +- tasks (Python functions registered in the `Registry`) +- pipelines (a `KgPipe` connecting tasks via input/output `Data`) +- metrics/evaluations (evaluators + metrics run on a `KG`) + +## See also +- `experiments/examples/`: a minimal example project using KGpipe +- `docs/reproduce.md`: running the (deprecated but working) reproduction experiments + +## Install + +From the repo root: + +```bash +pip install -e . +``` + +If you need the optional ML stack (transformers / sentence-transformers), install extras: + +```bash +pip install -e ".[ml]" +``` + +## Create a new experiment (recommended starting point) + +The easiest way to get a working project layout is to copy the template in `experiments/examples/`. + +```bash +cd experiments/examples +./init.sh +``` + +The script creates a new directory containing a Python package with example tasks/pipelines. Then: + +```bash +cd "" +pip install -e . +``` + +## Define tasks + +Tasks are normal Python callables registered via `@Registry.task(...)`. See +`experiments/examples/src/kgpipe_examples/task_examples.py` for canonical examples. + +Key concepts: +- **`input_spec` / `output_spec`**: the expected formats for inputs/outputs +- **`TaskInput` / `TaskOutput`**: dict-like objects mapping names to `Data` +- **`trace_task_run`**: wraps the function to produce a run report + +Minimal pattern (simplified from the examples): + +```python +from kgpipe.common import TaskInput, TaskOutput, trace_task_run +from kgpipe.common.registry import Registry + +@trace_task_run +@Registry.task( + input_spec={"input": "some_format"}, + output_spec={"output": "some_other_format"}, + description="Example task", +) +def my_task(inputs: TaskInput, outputs: TaskOutput): + outputs["output"].path.touch() +``` + +## Define and run a pipeline (Python API) + +Pipelines connect tasks by passing `Data` (path + format) between them. A minimal example exists in +`experiments/examples/src/kgpipe_examples/pipe_examples.py`. + +The core pattern: + +```python +from kgpipe.common import KgPipe, Data + +# tasks = [task_a, task_b, ...] # registered task callables (from your package) +# seed = Data(path=..., format=...) +# result = Data(path=..., format=...) +pipe = KgPipe(tasks=tasks, seed=seed, data_dir="/tmp/my_run_dir") +pipe.build(source=seed, result=result) +pipe.run() +``` + +## Discover components and inspect what’s available (CLI) + +The CLI entrypoint is `kgpipe` (see `pyproject.toml`). + +To register tasks/pipelines/metrics from your local package, import it via discovery: + +```bash +# From inside your experiment venv / environment +kgpipe discover --package --show-results +``` + +You can also discover from a local module path (directory or file): + +```bash +kgpipe discover --module-path ./src/ --show-results +``` + +To list what KGpipe currently knows about (after discovery): + +```bash +kgpipe list --type tasks +kgpipe list --type metrics +``` + +To show details for a specific task: + +```bash +kgpipe show --type task +``` + +To print YAML templates for evaluation configs: + +```bash +kgpipe show metric-config-templates +``` + +## Run a single task (CLI) + +KGpipe can execute a registered task directly. The `--input/--output` syntax is: + +\[ +\texttt{|@} +\] + +(`@` is optional.) + +Example: + +```bash +kgpipe task \ + --input "/tmp/in.txt|txt@input" \ + --output "/tmp/out.txt|txt@output" +``` + +Tip: if you get “Task not found”, run `kgpipe discover ...` first. + +## Run a minimal evaluation (Python API) + +KG evaluation is done via evaluators + metric names. See +`experiments/examples/src/kgpipe_examples/eval_examples.py` for a working minimal example. + +Example sketch (statistical metrics): + +```python +from kgpipe.common.model.kg import KG +from kgpipe.common.model.default_catalog import BasicDataFormats +from kgpipe.evaluation.aspects.statistical import StatisticalEvaluator, StatisticalConfig, EntityCountMetric + +kg = KG(id="my_kg", name="My KG", path="my_kg.nt", format=BasicDataFormats.RDF_NTRIPLES) +results = StatisticalEvaluator().evaluate( + kg, + metrics=[EntityCountMetric().name], + config=StatisticalConfig(name="default"), +) +``` \ No newline at end of file diff --git a/docs/reproduce.md b/docs/reproduce.md index 40875e6..70b1d69 100644 --- a/docs/reproduce.md +++ b/docs/reproduce.md @@ -1,4 +1,4 @@ -# Rep Experiments +# Rep Experiments (Deprecated but working) Guidelines to run the [experiments](../experiments) - see also [moviekg](../experiments/moviekg/README.md) diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..7a41041 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,52 @@ +site_name: KGpipe +site_description: Knowledge Graph pipeline evaluation framework + +# For GitHub Pages under // +use_directory_urls: true + +theme: + name: material + features: + - navigation.instant + - navigation.tracking + - navigation.sections + - navigation.expand + - navigation.top + - toc.integrate + - search.suggest + - search.highlight + +markdown_extensions: + - admonition + - toc: + permalink: true + - pymdownx.superfences + - pymdownx.details + +plugins: + - search + +docs_dir: docs +site_dir: site + +nav: + - Home: index.md + - Quickstart: quickstart.md + - Concepts: + - Tasks: tasks.md + - Pipelines: pipelines.md + - Configuration: configuration.md + - Parameters: parameters.md + - Meta KG: metakg.md + - Evaluation: + - Overview: evaluation.md + - Metrics index: metrics/metrics.md + - Entity coverage: metrics/entity_coverage.md + - Reference entity alignment: metrics/reference_entity_alignment.md + - Reference triple alignment: metrics/reference_triple_alignment.md + - Stats counts: metrics/stats_counts.md + - Experiments: + - Reproduce MovieKG: reproduce.md + - Other: + - Migration: migration.md + - View/UI: view.md diff --git a/pyproject.toml b/pyproject.toml index 57a7d1b..13cbf96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,10 @@ dependencies = [ [project.optional-dependencies] dev = ["pytest", "pytest-mock", "pytest-cov", "ruff", "black"] +docs = [ + "mkdocs-material", + "mkdocstrings[python]", +] cpu = [ "torch", "torchvision", From ccdf436f2f1e903be3aede72631d1acd3fc0dd83 Mon Sep 17 00:00:00 2001 From: Marvin Date: Wed, 3 Jun 2026 16:32:06 +0200 Subject: [PATCH 38/42] cleanup: docu refactor; consitency metrics update --- README.md | 49 +- docs/README.md | 45 + docs/adoption.md | 85 ++ docs/evaluation.md | 235 ++---- docs/index.md | 36 +- docs/metrics/entity_coverage.md | 2 +- docs/migration.md | 7 + docs/quickstart.md | 37 +- docs/workflow.png | Bin 0 -> 581605 bytes experiments/moviekg/Makefile | 95 +-- experiments/moviekg/README.md | 91 +- experiments/moviekg/env | 13 +- experiments/moviekg/eval.sh | 10 - experiments/moviekg/pipeline.conf | 86 +- experiments/moviekg/src/moviekg/config.py | 24 +- .../src/moviekg/evaluation/__init__.py | 0 .../moviekg/src/moviekg/evaluation/helpers.py | 206 ----- .../moviekg/evaluation/test_eval_refactor.py | 263 ------ .../evaluation/test_inc_msp_evaluation.py | 61 -- .../evaluation/test_inc_ssp_evaluation.py | 58 -- .../src/moviekg/evaluation/test_ref_dev.py | 247 ------ .../moviekg/evaluation/test_sensitivity.py | 159 ---- .../moviekg/src/moviekg/paper/__init__.py | 0 .../moviekg/src/moviekg/paper/config.py | 135 --- .../src/moviekg/paper/helpers/__init__.py | 0 .../src/moviekg/paper/helpers/agggregate.py | 224 ----- .../src/moviekg/paper/helpers/getter.py | 557 ------------- .../src/moviekg/paper/helpers/helpers.py | 777 ----------------- .../src/moviekg/paper/helpers/ranking.py | 119 --- .../moviekg/src/moviekg/paper/test_figtab.py | 779 ------------------ .../src/moviekg/paper/test_ranksens.py | 162 ---- .../moviekg/src/moviekg/pipelines/helpers.py | 9 +- mkdocs.yml | 3 +- src/kgpipe/cli/eval_new.py | 46 ++ src/kgpipe/io/__init__.py | 2 + src/kgpipe/io/pipe_out.py | 122 +++ .../metrics/consistency_violations.py | 634 +++++++++++++- src/kgpipe_eval/utils/kg_utils.py | 9 + 38 files changed, 1246 insertions(+), 4141 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/adoption.md create mode 100644 docs/workflow.png delete mode 100644 experiments/moviekg/eval.sh delete mode 100644 experiments/moviekg/src/moviekg/evaluation/__init__.py delete mode 100644 experiments/moviekg/src/moviekg/evaluation/helpers.py delete mode 100644 experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py delete mode 100644 experiments/moviekg/src/moviekg/evaluation/test_inc_msp_evaluation.py delete mode 100644 experiments/moviekg/src/moviekg/evaluation/test_inc_ssp_evaluation.py delete mode 100644 experiments/moviekg/src/moviekg/evaluation/test_ref_dev.py delete mode 100644 experiments/moviekg/src/moviekg/evaluation/test_sensitivity.py delete mode 100644 experiments/moviekg/src/moviekg/paper/__init__.py delete mode 100644 experiments/moviekg/src/moviekg/paper/config.py delete mode 100644 experiments/moviekg/src/moviekg/paper/helpers/__init__.py delete mode 100644 experiments/moviekg/src/moviekg/paper/helpers/agggregate.py delete mode 100644 experiments/moviekg/src/moviekg/paper/helpers/getter.py delete mode 100644 experiments/moviekg/src/moviekg/paper/helpers/helpers.py delete mode 100644 experiments/moviekg/src/moviekg/paper/helpers/ranking.py delete mode 100644 experiments/moviekg/src/moviekg/paper/test_figtab.py delete mode 100644 experiments/moviekg/src/moviekg/paper/test_ranksens.py create mode 100644 src/kgpipe/io/__init__.py create mode 100644 src/kgpipe/io/pipe_out.py diff --git a/README.md b/README.md index 2900b0a..3c463c3 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,21 @@ # KGpipe: A Framework for Knowledge Graph Integration Pipelines -- 📊 [Benchmark Datasets](https://doi.org/10.5281/zenodo.17246357) +## Related benchmarks & datasets + +- **KGI-Bench**: benchmark specification + tooling for KG integration evaluation. See `https://github.com/ScaDS/KGI-Bench`. +- **KGI-Bench (Movies)**: Movie-domain benchmark dataset release (Zenodo). See `https://doi.org/10.5281/zenodo.17246357`. KGpipe is an open-source framework for defining, executing, and evaluating knowledge graph (KG) integration pipelines. It enables the reuse and composition of existing tools (e.g., OpenIE, PARIS, JedAI) and Large Language Models (LLMs) into modular pipelines that integrate heterogeneous data sources into a unified KG. +![KGpipe workflow](docs/workflow.png) + +**Who is this for?** +- You have multiple heterogeneous sources (RDF/JSON/text) and want a **reproducible, modular pipeline**. +- You want to **reuse existing tooling** (Python libs, Dockerized CLIs, remote APIs/LLMs) without rewriting everything. +- You want to **evaluate** generated KGs with a growing set of metrics (`kgpipe_eval`). + **Key features:** - Modular and extensible pipeline specification. - Support for multiple execution backends (Python, Docker, HTTP services). @@ -13,6 +23,28 @@ It enables the reuse and composition of existing tools (e.g., OpenIE, PARIS, Jed - Novel benchmark for systematic evaluation of pipelines across RDF, JSON, and text sources. - Metrics covering structural, semantic, and reference-based evaluation. +## Quickstart (5 minutes) + +Install from source (editable): + +```bash +pip install -e . +kgpipe --help +``` + +Bootstrap a minimal example project and discover its tasks: + +```bash +cd experiments/examples +./init.sh + +cd "" +pip install -e . + +kgpipe discover --package --show-results +kgpipe list --type tasks +``` + ## Architecture Each pipeline is a sequence of tasks with well-defined input/output contracts. @@ -49,7 +81,18 @@ KGpipe provides Single-Source Pipelines (SSPs) and Multi-Source Pipelines (MSPs) ## Usage -For documentation see the [docs](docs/reproduce.md) +Documentation lives in `docs/`: +- **Start here**: `docs/index.md` and `docs/quickstart.md` +- **Adopting KGpipe / wrapping existing tools**: `docs/adoption.md` +- **Evaluation (new API)**: `docs/evaluation.md` (uses `kgpipe_eval`) +- **MovieKG reproduction**: `docs/reproduce.md` + +### Documentation site (GitHub Pages) + +This repo is set up to build docs with **MkDocs + Material**: +- config: `mkdocs.yml` +- local build instructions: `docs/README.md` +- deploy workflow: `.github/workflows/docs.yml` (GitHub Pages via Actions) ## Installation notes (CPU vs CUDA) @@ -76,4 +119,4 @@ uv pip install ".[ml,cuda]" ``` ## Experiments -- **[moviekg](experiments/moviekg/README.md)** evalaution of a pipelines, building a Movie KG from three sources (rdf,json,text). +- **[moviekg](experiments/moviekg/README.md)** evaluation of pipelines, building a Movie KG from three sources (rdf, json, text). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b066c6a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,45 @@ +# Docs (MkDocs) + +This repository uses **MkDocs + Material** to build the documentation site from the Markdown files in `docs/`. + +## Local preview + +### Option A: pip + +```bash +python -m pip install -e ".[docs]" +mkdocs serve +``` + +Then open the URL shown in the terminal (usually `http://127.0.0.1:8000/`). + +### Option B: uv (recommended if you use uv) + +```bash +uv pip install -e ".[docs]" +mkdocs serve +``` + +## Build + +```bash +mkdocs build --strict +``` + +The static site is written to `site/`. + +## Navigation / sidebar + +Edit `mkdocs.yml` (`nav:` section) to control: +- sidebar structure +- ordering +- page titles + +## Deployment (GitHub Pages) + +Deployment is handled by the GitHub Actions workflow: +- `.github/workflows/docs.yml` + +In your GitHub repo settings, set: +- **Settings → Pages → Source**: **GitHub Actions** + diff --git a/docs/adoption.md b/docs/adoption.md new file mode 100644 index 0000000..c1229a5 --- /dev/null +++ b/docs/adoption.md @@ -0,0 +1,85 @@ +# Adopting KGpipe (integrating existing pipelines/tools) + +This page explains how to **adopt KGpipe** when you already have: +- an existing KG pipeline (e.g., DBpedia-style multi-step workflows), and/or +- existing implementations you want to reuse (Python code, Dockerized tools, external APIs). + +The goal is to map “what you already have” onto KGpipe’s building blocks: +- **Tasks**: reusable steps with typed inputs/outputs (`input_spec` / `output_spec`) +- **Pipelines**: ordered task graphs (`KgPipe`) that transform `Data` from seed → result +- **Configuration**: parameters passed into tasks (often via env/config profiles) + +## 1) Convert an existing pipeline into a KGpipe pipeline + +When you have a pipeline described elsewhere (scripts, Airflow, Makefile, DBpedia extraction steps, etc.), do this: + +1. **List pipeline steps** (one row per step): name, inputs, outputs, and “how it runs” (Python/Docker/API). +2. **Define formats** for each boundary artifact (RDF formats, CSV, JSON, text). If needed, extend formats. +3. **Wrap each step as a KGpipe task** (see sections below). +4. **Compose tasks into a `KgPipe`** and verify the input/output formats connect. + +Practical tip: start by wrapping a *single* step and run it via `kgpipe task ...`, then grow into a pipeline. + +## 2) Wrap existing tasks (three common patterns) + +### A) Wrap a Dockerized CLI tool + +Use this when the tool is a command-line program and can run inside a container. + +Reference example: +- `src/kgpipe_tasks/entity_resolution/matcher/paris_rdf_matcher.py` + +What to document for each wrapper: +- Docker image name + how to build/pull it +- command template (mapping KGpipe input/output keys to CLI args) +- volume mounts / working dir assumptions +- required environment variables + +### B) Wrap existing Python code + +Use this when you have Python functions/classes you want to call directly. + +Reference example: +- `experiments/param-opti/src/param_opti/tasks/base_linker.py` + +What to document for each wrapper: +- the function/class you call +- how you read from `inputs[...]` and write to `outputs[...]` +- how you map configuration parameters into function args (or config objects) + +### C) Wrap an external API (HTTP service) + +Use this when the implementation is “some service endpoint” (DBpedia Spotlight, LLM providers, etc.). + +Reference examples: +- `experiments/param-opti/src/param_opti/tasks/spotlight_lib.py` +- `experiments/param-opti/src/param_opti/tasks/spotlight.py` + +What to document for each wrapper: +- endpoint URL + auth +- request/response format +- retry/timeouts and caching +- how you handle rate limits and partial failures + +## 3) Discovery (making your tasks available) + +Once tasks exist in a Python package, KGpipe can discover them (they register when imported). + +```bash +kgpipe discover --package --show-results +kgpipe list --type tasks +``` + +## 4) Recommended structure for “adopted” pipelines + +A maintainable layout usually separates: +- `tasks/`: wrappers (Python/Docker/API) +- `pipelines/`: composition (KgPipe builders or pipeline configs) +- `configs/`: pipeline/task configuration profiles +- `docker/`: Dockerfiles and wrapper scripts (if needed) + +## Status + +This page is the intended replacement for `migration.md` (which was a misleading name). It will be expanded with +copy-pastable code snippets for each wrapper type using the referenced files above as canonical examples. + diff --git a/docs/evaluation.md b/docs/evaluation.md index fc27bda..613eb33 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -1,181 +1,114 @@ -# KG Evaluation +# KG Evaluation (new API) -The framework provides several approaches to evaluate the quality of a generated knowledge graph. Evaluation is organized into different aspects, each focusing on specific quality dimensions. +KGpipe currently contains **two** evaluation implementations: -## Evaluation Aspects +- **New** (recommended): `kgpipe_eval` (package: `src/kgpipe_eval/`) +- **Old** (deprecated soon): `kgpipe.evaluation` (package: `src/kgpipe/evaluation/`) -The framework supports evaluation across multiple aspects: +This page documents the **new** `kgpipe_eval` API. -- **Statistical**: Basic metrics like triple count, entity count, graph density, and other structural properties -- **Semantic**: Validation of ontology consistency, type errors, relation direction, and semantic correctness -- **Reference**: Comparison against curated gold-standard knowledge graphs using precision, recall, and F1 scores -- **Efficiency**: Resource consumption metrics including runtime, memory usage, and cost +## Mental model -## Using the Evaluator +In `kgpipe_eval`, evaluation is composed from: +- **KG loader / adapter**: turns a `KgLike` (e.g. a `kgpipe.common.model.kg.KG`) into an in-memory `TripleGraph` +- **Metric instances**: objects implementing `Metric.compute(...) -> MetricResult` +- **Metric configs** (optional): typed config objects passed to metrics that require parameters +- **Evaluator**: runs multiple metrics against a loaded graph -The main entry point for evaluation is the `Evaluator` class. You configure which aspects to evaluate and then run evaluation on a knowledge graph: +Key types: +- `kgpipe_eval.api.Metric`: metric interface (`key`, `description`, `compute`) +- `kgpipe_eval.api.MetricResult`: dataclass with `measurements` + optional `summary` +- `kgpipe_eval.evaluator.Evaluator`: runs a list of metrics with an optional `confs` dict + +## Minimal example (statistics) ```python -from kgpipe.evaluation import Evaluator, EvaluationConfig, EvaluationAspect -from kgpipe.common.models import KG, DataFormat from pathlib import Path -# Create evaluation configuration -config = EvaluationConfig( - aspects=[EvaluationAspect.STATISTICAL, EvaluationAspect.SEMANTIC, EvaluationAspect.REFERENCE], - metrics=None # None means use all available metrics for each aspect -) +from kgpipe.common.model.data import DataFormat +from kgpipe.common.model.kg import KG -# Create evaluator -evaluator = Evaluator(config) +from kgpipe_eval.evaluator import Evaluator +from kgpipe_eval.metrics.statistics import CountMetric +from kgpipe_eval.utils.kg_utils import KgManager -# Load the knowledge graph to evaluate kg = KG( id="my_kg", - name="My Knowledge Graph", - path=Path("result.nt"), - format=DataFormat.RDF_NTRIPLES + name="My KG", + path=Path("my_kg.nt"), + format=DataFormat.RDF_NTRIPLES, ) -# For reference-based evaluation, provide reference data -references = { - "gold_standard": Data(path=Path("gold_standard.nt"), format=DataFormat.RDF_NTRIPLES) -} - -# Run evaluation -report = evaluator.evaluate(kg, references=references) +tg = KgManager.load_kg(kg) +results = Evaluator().run(tg, metrics=[CountMetric()]) -# Access results -print(f"Overall score: {report.overall_score}") -for aspect_result in report.aspect_results: - print(f"{aspect_result.aspect.value}: {len(aspect_result.metrics)} metrics") - for metric in aspect_result.metrics: - print(f" {metric.name}: {metric.value} (normalized: {metric.normalized_score})") +for r in results: + print(r.metric.key, r.summary) + for m in r.measurements: + print(" ", m.name, m.value) ``` -## Evaluation via CLI - -You can also evaluate knowledge graphs using the command-line interface: - -```bash -kgpipe eval target.nt --ground-truth gold.nt --aspects statistical semantic reference --output results.json -``` - -The CLI supports: -- `--aspects`: Specify which aspects to evaluate (statistical, semantic, reference, efficiency) -- `--metrics`: Filter to specific metrics by name -- `--ground-truth`: Path to reference knowledge graph for reference-based evaluation -- `--output`: Save evaluation results to a JSON file - -## Statistical Evaluation - -Statistical evaluation provides basic metrics about the knowledge graph structure: - -- Triple count -- Entity count -- Relation count -- Graph density -- Average degree -- Connected components - -These metrics help understand the scale and structure of the generated knowledge graph. - -## Semantic Evaluation +## Metrics that need configuration -Semantic evaluation validates the knowledge graph against its ontology: +Some metrics require a config object. The `Evaluator` detects this by introspecting the metric’s +`compute(...)` signature: +- `compute(self, kg)` → no config needed +- `compute(self, kg, config)` → config required and must be provided -- Disjoint domain violations -- Incorrect relation direction -- Incorrect relation cardinality -- Incorrect relation domain/range -- Incorrect datatypes -- Ontology class coverage -- Ontology relation coverage -- Namespace coverage +You pass configs via a dict keyed by the metric key/class name. -These metrics ensure the knowledge graph conforms to its schema and maintains semantic consistency. +Example (triple alignment + duplicates): -## Reference-based Evaluation - -Reference-based evaluation compares the generated knowledge graph against a gold standard: - -- Entity matching (precision, recall, F1) -- Relation matching (precision, recall, F1) -- Triple alignment -- Source typed entity coverage -- Reference class coverage - -This type of evaluation requires a curated reference knowledge graph that serves as ground truth. - -## Evaluation Reports - -Evaluation results are returned as `EvaluationReport` objects that contain: +```python +from kgpipe_eval.evaluator import Evaluator +from kgpipe_eval.utils.kg_utils import KgManager + +from kgpipe_eval.metrics.duplicates import DuplicateMetric, DuplicateConfig +from kgpipe_eval.metrics.triple_alignment import TripleAlignmentMetric, TripleAlignmentConfig +from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig + +tg = KgManager.load_kg("path/to/result_eval.nt") # KgLike: path, KG object, ... + +metrics = [DuplicateMetric(), TripleAlignmentMetric()] +confs = { + "DuplicateMetric": DuplicateConfig( + entity_alignment_config=EntityAlignmentConfig( + method="label_embedding", + verified_entities_path="path/to/verified_entities.tsv", + verified_entities_delimiter="\\t", + entity_sim_threshold=0.95, + ) + ), + "TripleAlignmentMetric": TripleAlignmentConfig( + reference_kg="path/to/reference.nt", + entity_alignment_config=EntityAlignmentConfig( + method="label_embedding", + reference_kg="path/to/reference.nt", + entity_sim_threshold=0.95, + ), + value_sim_threshold=0.5, + cache_literal_embeddings=True, + cache_ref_literal_embeddings=True, + ), +} -- The evaluated knowledge graph -- Reference data used (if any) -- Aspect results for each evaluated aspect -- Individual metric results with values and normalized scores -- Overall score (average of normalized scores across all metrics) +results = Evaluator().run(tg, metrics, confs) +``` -Reports can be serialized to JSON for storage and later analysis: +## Canonical reference example (MovieKG) -```python -report.to_json("evaluation_results.json") -``` +For a realistic end-to-end usage example (loading pipeline stage outputs, wiring configs, running multiple metrics), +see: -# Hierarchy +- `experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py` -``` -QualityEvaluationOntology - -QualityDimension - ├─ Accuracy - ├─ Coverage - ├─ Consistency - └─ Uniqueness - -Metric - ├─ BaseMetric - │ ├─ Precision - │ └─ Recall - ├─ CompositeMetric - │ └─ F1Score - └─ AggregatedMetric - ├─ MacroAverage - └─ MicroAverage - -QualityIssue - ├─ DuplicateEntities (false positives for EM) - ├─ DisjointDomainIssue - └─ MissingEntities (false positives for OM, or true positives for EM) - -EvaluationArtifact - ├─ ReferenceDataset - └─ QualityRulePattern -``` +That file shows how to: +- build per-metric configs (duplicates/entity alignment/triple alignment) +- load the KG from a pipeline output directory +- serialize `MetricResult` to JSON (because it contains metric objects) -Example Instance: Entity Matching +## CLI note -``` -ReferenceDataset - │ - ▼ -Precision / Recall - │ - ▼ -F1Score - │ - ▼ -Evaluation of Matching Quality - │ -False Negatives - │ - ▼ -DuplicateEntities - │ - ▼ -RedundancyIssue - │ - ▼ -Violates Uniqueness Dimension -``` \ No newline at end of file +There is a “new eval” CLI command path intended to run these metrics (see `kgpipe_eval.api` docstring mentioning +`kgpipe eval-new`). If you want the docs to include the CLI, we should first confirm the exact CLI flags and expected +inputs in `src/kgpipe/cli/eval_new.py` and align this page with that implementation. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 8d9032a..b506b88 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,6 +7,8 @@ The framework is organized into three main subpackages: `kgpipe` contains the co **Current version**: 0.7.0 **Python**: >= 3.12 +![KGpipe workflow](workflow.png) + ## Quickstart Start here: [Quickstart guide](quickstart.md) @@ -35,42 +37,10 @@ cd experiments/examples - Evaluate generated KGs: [Evaluation](evaluation.md) and [Metrics](metrics/) - Understand the internal “PipeKG”: [Meta KG](metakg.md) -## Meta KG - -[link](metakg.md) - -KGpipe uses an internally maintained Meta KG (PipeKG) to maintain tasks, tool implementations, their components, pipelines, and metrics. This knowledge base enables automatic pipeline generation and tracking of execution results. - -## Task Specification - -[link](tasks.md) - -The framework enables the description and integration of integration tasks. You can describe tasks with Python, interface existing implementations with Python, Docker, or remote API requests. Tasks are discovered and registered through the framework's discovery mechanism. - -## Pipeline Generation and Execution - -[link](pipelines.md) - -KGpipe allows you to define pipelines manually or using an automatic search algorithm that operates on the PipeKG knowledge base and a set of given constraints. You can swap subpipelines or single tasks with other components to experiment with different approaches. - -## Configuration - -[link](configuration.md) - -The framework supports configuration at multiple levels. The main configuration is specified in `kgpipe.yml`, and individual tasks can define their own configuration parameters that will be passed by the framework when executing pipelines. - -## Evaluation - -[link](evaluation.md) - -The framework provides several approaches to evaluate the quality of a generated knowledge graph, including accuracy, coverage, consistency, statistics, and efficiency measurements. Evaluation metrics are tracked in the Meta KG alongside pipeline results. - -Additional evaluation metrics are documented in the [metrics](metrics/) directory, such as [entity coverage](metrics/entity_coverage.md). - ## Other Links - [Reproducing the movie kg experiments for 15 pipelines](reproduce.md) (rdf, json, text) -- [Migration notes](migration.md) +- [Adopting KGpipe (integrating existing pipelines/tools)](adoption.md) - [UI / viewer](view.md) ## Docs backlog diff --git a/docs/metrics/entity_coverage.md b/docs/metrics/entity_coverage.md index 84b4ff1..1aa3ea5 100644 --- a/docs/metrics/entity_coverage.md +++ b/docs/metrics/entity_coverage.md @@ -1,4 +1,4 @@ -# Entity Coverage Metric +# Entity Coverage Metric (OLD) The Entity Coverage metric evaluates how well source entities are integrated into the target knowledge graph. It measures the overlap between expected source entities and the entities actually present in the generated knowledge graph. diff --git a/docs/migration.md b/docs/migration.md index e69de29..1ca2964 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -0,0 +1,7 @@ +# Migration (renamed) + +This page was renamed to better reflect its intent. + +Use: +- [`adoption.md`](adoption.md): **Adopting KGpipe (integrating existing pipelines/tools)** + diff --git a/docs/quickstart.md b/docs/quickstart.md index b50f1ec..a2b08f4 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -139,22 +139,35 @@ kgpipe task \ Tip: if you get “Task not found”, run `kgpipe discover ...` first. -## Run a minimal evaluation (Python API) +## Run a minimal evaluation / metrics (Python API, new `kgpipe_eval`) -KG evaluation is done via evaluators + metric names. See -`experiments/examples/src/kgpipe_examples/eval_examples.py` for a working minimal example. +KG evaluation is being migrated to the **new** `kgpipe_eval` package (recommended). A realistic integration-style +example exists in: -Example sketch (statistical metrics): +- `experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py` + +Minimal example (basic statistics): ```python +from pathlib import Path + +from kgpipe.common.model.data import DataFormat from kgpipe.common.model.kg import KG -from kgpipe.common.model.default_catalog import BasicDataFormats -from kgpipe.evaluation.aspects.statistical import StatisticalEvaluator, StatisticalConfig, EntityCountMetric - -kg = KG(id="my_kg", name="My KG", path="my_kg.nt", format=BasicDataFormats.RDF_NTRIPLES) -results = StatisticalEvaluator().evaluate( - kg, - metrics=[EntityCountMetric().name], - config=StatisticalConfig(name="default"), + +from kgpipe_eval.evaluator import Evaluator +from kgpipe_eval.metrics.statistics import CountMetric +from kgpipe_eval.utils.kg_utils import KgManager + +kg = KG( + id="my_kg", + name="My KG", + path=Path("my_kg.nt"), + format=DataFormat.RDF_NTRIPLES, ) + +tg = KgManager.load_kg(kg) +results = Evaluator().run(tg, metrics=[CountMetric()]) + +for r in results: + print(r.metric.key, r.summary) ``` \ No newline at end of file diff --git a/docs/workflow.png b/docs/workflow.png new file mode 100644 index 0000000000000000000000000000000000000000..41239b06697dbbaad6485f3db1a7f2ebeae3d13f GIT binary patch literal 581605 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ν zmCKAcY9!1R{23ZXFMi|*A59WD#;WE)Gn|GFdV zSE8Qr_hMa9z19ey!xa8p`8AEkN9(k20s zBzjy_lHPF=Vk78iMrflHH&+8yy3-v#1>}-b?6B8s!cAxqidNs@(4)v7In#HsjZskS zU0K*+;ZxElh-G$I$_`uY{K=7EWyV=Qy%xmn0+Aal;?Cb@=yr|!&>AlW13oft1|g#-}}cI4}?t1?M7^3U`pKIR8#yhZk05$8hBgU#~Fs z=Fv4Z7mQ~_#P~c#i|Xq8?1^9yQDrYy?)g)^6<#LY`INDKm{$H49k+EvPTQ4*cETE5HX(4aMard z%tM6EhLx83J-5j_-)HX+eG4KyY1x^khAVS}-s=?ZoO54X*6h4fPbIoztGc?ndgmwO zg4oLudGOOhV`o&evUpbYv#+$$8aHkt?$o;3$|@N@O5A7>^AW7@ZjqOp1c>d z(3d{zMX}ttuVdmrEP(BIaY(OK(Y#JDYwoegv@GIx)6H4l0FpD&eI|j(iR5Y8KAF)9 z1@4-}QzQN8dzL99(Nb4OnZb`e$ly(=d9_L~T4-$b4F&vVb^P0B8*gl_v-Lc`j|f}e z2H;C~#l}_FZ)|Y3VRQBvEf7j8PX=zolR9q5CX@Jj9In9dFU6T{1#>Ngo+kgKm-^{2 zHpkuyAGUL79WJpI8;GnpdB(iuDsAD^B7qtK=KsjS^0{`*z>s(hpRV4cjnMjcHNs)ay`h`RY@@jn#hLJZaX9 z>&N~fIDzF5=+*2QQwO8pb)?r8h)|Uv6CJqt_|i;%KWpKf#U_-d{Z(*Prmw|#B=5H% z%qrLuw7_w8n4VEI-wl*>B&2&-6v+u%7p679pI7lGSnVsNYEfZqCi2 ztM;z)HVSEiE^9eMw@+i~mEvJ~to~3(oqk>(s$i>z$5uh?_sk}1-tbILI_cbh1=m{@ zxu@r=(Y{SFAG#o!25s=0h?kbjmLzL&La7unVwT+sug;}yUN61N@$(8WTtLc#R6M<& zC}z<%Q&QGUf-vSpE@i;G)C8|AAPp5!7&aC7?{90rzok5TP!J};P7xkCIl1fDSX!+L zXzRCLg|G(ajoIh9(aZ)V9#3SHlvIU%f2gSbzGv*%U@>toQPne!UX!`hCOdGe$*3N^ z%O|qcdu)HW*!a3HA-+)7GTohAXf=<+GX3Md)v?z@Qroi}g&D7>RpPV1D@EPUbBB=b zKV##06s@Q85DO16v8xsq?aw25V=&BO+<3P1nkSG)?Dd6$<%3M$?vT-zgL{YplA)`E z*bb0+Z`%sra?f;JIp;^FAnVZ}c#$Z#xs`Q`xo(ToyB0UeM1RfhNF zHYS((g@PBd2#IzoJ-jf3YnkRdemwD$Fmxa?GO}ZB`ZnijSA(T~D6MlR1dUV#1HU(b zrf-NBW*5@d%AL1{;nM8jYZW@_-95pVCHXwCLd5sY2(7ET$7=fHJReP6E=Hkk9|*ZnPRGtpWw(ZGu2xA%ODj7cKnMncPHH(x+82AJWIj+MDOM zn)q$hkvIRudF!=_iMmygGYGjc_R!tydXU1PZ5LLh>Fc^Nglf{Tr-sEY=O08Uj;%Y{ z|Jc%y-)AJGt2p_F2_tsWdl^Nd#vj&58$yG|kF8377SyxR+>P=&L=M{t+r;~_tM;eB zHfvM}Cd9^T>wICzCR_Cn%LFuYLJ$e-14P{3eb#gd4<~|vS*F^r>c5v&6*j8;+tD9w zmcy%JJj^U5(;9nek?~yB{ z^}9&e*QwSJv*}Z@Ob@9*o>dM}`JGG^rU@Qb9S)A0UXE&tuOFGmO6xjWbR*etttB>_y5R93#9Y7}jHTXVkVLxrmV9LO!tLqyp*k5o^lwD^)D))uBkcK2% zT~GL#P|nz56eK!q(X;|@#^8y6?Yj#s?!_m;(SC<}HmxLzNVGxr0~ zBB1A`=Cl=hUr)3+0*Y?iWWlurDp!XH(CAEDB{ZJ049Y}IH{CxR-9T&*Ccc`c8N`V) zz27|Zm;&Xm7J$Na)?UTUz-dtJQFwt>rDpbz8!$Z>W)QqB{&gIHeP{qK7~AQ9h`5F} zJQu*?AwqY>0m_G_Gm{T8>_|f6Q$H)|HB9{~F!uNRo`)l+CSOjC312LU218ASd{(;~ zMEp(ho;ybg%aYb&9`&t^;J1=}!>Qp5Zb zcS!bq#3>FgcfA%aHtl+gs)nU-|It&sy4nwq0NulKFeT)(Wt!CL$W7f3Vr-c)q?dHmu>OaigMPY#G#rN6$Z76MR|y_}6cl^cx9c%=GeC@}Lng z6+TS3P27H0$~j`Fpng3h_L+cR*K!LA?=NeBgjgiKai`Nm3 zzOb|uSq&!yqy=CJA$Xxw8C+f!92^}d z5cdwQ%u&n|>=D*<5TBt7Pj*C)rj^nwqO`~O&`1Y9vUIC>92NbrG2-`YvCGXtDDrXE%-e zH$E>X-)d#1!8g8GD?=7VW1N|>_3s|ncvq^Y-&Yk=Z53-f^)H0BqP*$KaUE4XSELNp z5Y){KL5*ja&~d&%Yq#hO{jAwIQg|&qN}eLZ%Exhh9C&oP62^Z}Ids2qKTfg+h+qt7 zVY2sVv_CiQV3O^ z1F*(P`QTj0B5^Qr*=hVG0B<01OVP)oK-a|Gz@>;fPA09+2-$~hA66}7>Oap0rWt9O z$`FMTu+vaRcSFG&wrz&HTx4G+R)67#XuMZtcQYrWAfS>m3;wx~6XJ3j^9tuMu5GF~ z#m&Pb#7FZQ&^A@IHS;yo>Uei?DIF)n`OOu>8V)u%-{^eayCY~}mg8`O5t3?Ok7auH z%jL%py@J@gJak9#;=Wf)YD4pvt*5{=E+Y7Iaj>yv(ZIWYfUE?!e$q?XU)#KHh_0@5 z`pHpzZPD|CX*f#=J$)bPKl}>T4+!=1PP(!Xs(9Eb1E0%m!j#*9C&H`sgVZ{l2os!4 zxOhU?+PX6#@*I~TOqiP^4-+ofT``&t<%;f_BoE#`^>f{{rg!3M%oB1q|IwMtYH_3m zuy7}X&z>RGPwO2YixLLgng`M30%jrjH#pQAd{c}rxC_Wh__N~Va{tEbe`~kEo2>vC z$wTWqHM!7`O|kl^dMz%hc-D1j({Q$pDK6CtACd4B+qV!J-vz*B9E8uYV}Sdj>kAD- zeq0}`X{L_mkgd}4vza4Kx~T4UJ%y*U-f$jgVKk8xa0hdoQ0_ zU7f=~zBifrm0T$QfuP`Sw^gYF)$ zhSyN*Hr{LSg6;hy6ssC^4?(o=Gq6ebNddru|ED(qbfc8%C*kcaE%ZCH`!^Z__8`Iq zSwJ&v;6)9Nt`1IkNzC5gkPx3JnRf~i~Xac2isT&)LCm}>r-ThDMof2*3A54&^STg(X zHQ5?l8L3s6dF%xbbdFsf+U{Pj06I2tXdK{4n}V)LNE{mlhu~1`3~?!ItsUyXy~>mwtpi)8y(hyNP*<^a25&4!iaKKA_Uqk0l)yD?-DGX>hz%d zMPq$OTv|v|4)TeTwEQ&{Xe*!~8lVNY(R*Dx8rY3WJbT_v7WhD? z(!9Ec^C}4Bb$Jh6oaQ`bF&DK}^f(c{%AXH(0-0fQm);(JPcn>xG;MrgFFP)(BE^>y?(8GI(CE6G-h;}{9#(B!6gHT;rF%lS8s=f@Ls zx0VFhl_uh*JSe{Ex<93=F5$c7V%fbv4J67>U?icR zM$>j~Dir;=vRr;yy-x;2+Z3Okk2AdEyAmj6M>2rO4`8X~X`xRDV{`J0KgNhZh7Q12 zaUWG5C;@GWqara*$AT6(c)$3LXby^ty|-W!{~%abLAjA=Kp3IOz~Zf}x>`s4E~d(9 zpz^F`x65XExOY*B?lRvz_I!ljSj-AMR;%|mSyhF1{XCVx!qgukT(Wm&A3axGFdr_cc0QawtXXc4cl|>OjuSYZ$?o zZS}$+k55s6TSVb|_IgE!VsN_}*psMHq3WKG0uDJ>6QHhlB;oKj@Nwn(r$~CKCa2!1E8w<-`kn0t0UQda7q4JFd z>?}^q*9qP$87g#k#LsI{+ws=e!)4FRFCkSMfrkO3kSdP7XO8+rcsf9s7{XB3xsO|JXOFfE?qN8hC zEfzgCLZvL9uA}l${4`71WPUg5G*&cgr*xd5Hp)EJ`a$!d$@rG|_4Og=F4nv^%BAZW zF`7m8f1v%FR%I!GtpMwB1tv(WVol7>)XturGih6qQOi1RBtSYPkJ%8FBfCkSm}<;^ zSf&e;PH4|xMgc9mY3rG@Ov{0bvQbe&X0!Mu_P#H{L%$(S+4|2-@GV9wL>yq+^u6wW z7Y#IMynK<9l%{`9eSxtB zo|kQQ1D9rJZ&}qK`qodx?c^$^c2FGqZp(c6zh1rq3k-=OKLSO$9*Fg{5tz3ltlkzU zr|BfX=@Grp(f%;;k7i<4$^EnaZxGX8%}$dEj+E5h+WZ_R;opQ`q|Hekr$H(0F^w+h zlg-_OVCB`B?tNeb$l>~Y6O^k~7d2A>aaZzrOzZ)hTizUs{+BzESBnI83Sg>+-DCo9 zpK0{7-Yu9rMDF`~yDzwxAN=+VP(|f@fubZd+%630@uB+iVnw1eb;lPj;m6o4;bh zFoQ_xuCUK^7dUObxHwv{VF$|NaQ0U;n>{>(qDU@rj&3tkJ)`Qia@w|cKo0-_6A*y^ z(dU?C#}h3$oyeO2ol&t;H=FFKJde($gQKHyOx;^#^v-Cm z4t{WEVKn2F#1=F!(S1i?G=F=)WsHmrjkNX6eq?br>pv5+8hJ=!*Lv1N z;Ipe|>eW3Yw)KCxkN#PV4Nz2`_qX)EfjIzhqO^LP>2(#Kah~`SvcgxiJQqmXCmyt` zVO{d%ty37LwCHRRrGBIIv9I6=cJIg%XxJ)x%qb|rjHcE(Xlfp^PIdDFb*5VSz6I7q zkRdEv#F@c~z6{mTQ$8yjP|QQ<*<}~jGg+em@io)5cpNJf>V7lYxTDy@c+4*Cx*soY zSBW9qQ*@L`)49%%&I(kTjYJ=kNDGh4bX=W?Z?s_gONnAhf4HS~Sz}Dc247y`|fk@jd0?lgYG4 z^4>s~h?Zhy0TuuHx6wdpksk@)d~28xz7z3oVH#y(rE?R~pHXIhkQM=zo6wAzyr+W>2q&*axioyJp~xA^b>Ul=!~Zh*-|wp= zQG@mKrGn#Rg%fEw7_vzdNWRfYnTR?o?`LDu@qrJ#krj=^a2MKM$NWSGGG^L&>)E1s z)|m6Yx`%9=6g%ShzlgN~^K*9aE+==sH`6Lk@9Fol{Ec^fI9MCnqH#=%KU`?^*k6ik z(0DKkIqzkwNaKH+yIA+4PV#Fym)o4x@f|sZ+R*$$yEvl1=S-TAnoRvIM&XND%*lqb z9=4v9LU1@_71Fo~&$&j!q!V=?mQkW}W*%!jMX98Y?sud~PzYiU@uy*sRT(vNnly-r zQ43eUULW#P_H!3|rY0kU3U3R4R-ok3F&7cfYVz?DBg{aHuqkk^#u-k-*=a;rrD1H) zjqqv+;FdnxD=%xhc*9o{-3=)FQ2UQKIztY*HWvz?Zi*ylWox}#caoNot=@#(*%j^5 z$n)6Vj-(2I&BM8y<~6orwu25=@0g?qGBQ73>(o$ipE-Otm}Pf}A-`G?YBN(ZWyJk@ zW^Rs0wYC?^fR|*83~oQ}&VAKCbaP5KX;NJC##@0T6Te|3q*v1S;4|__F<}wA(RYd0 zudPHY|2NE^yg~6@o#ONY^1&gN;VIV`t1`op14V)sCMa!7x>OXq!Lo`H`fjSM{CT0~ zTsr@bFVMoZmZvaF^rpuAEd>Q$g8xSQpyabYNaTNTpB7o1j98|Rznu18R=e5foq43|Ur*-i{hP5t^ zVsDS5aMp4IB18p5-@F|WQn4BlA2<`W_-;5wikZvU_wFb=0#yY60RA@Y zIXp>{9PmVLU%XwGd)Y_#)`IehsS4fzP`xuvMRaNJKV%e$p55`(wr;%>)^s+~bi^>p ztG~BcV1L&KIxWYQ!swH8q{&UAomSJ>jnQQ><(mEk!$QgK+lL$H3S4iRCwH|>AQ(mf zwXht&G7xTOKCL`yXX*N7$&?Pg1o|d;MVm|Z@shLZJWY`X42(G zCs_aGOH&lOYeK2k&999V-W3p87}aAT{g8*x4bp+UpYI6A{E68@6uKahTIfzHvJL3+ zmiPEw@|Llu;l|3c~SupkkF;Oi4s2y=*X7wA&}{A z-5aMG0!Y4^>V(jtC8$E*#-An}C_~+|I72w3IykNK@?i3Se-g>`>?{y^)t=yWn?av9 z0)8h!n()slq-eH!G8!_Xds~i{6f5BQlI|XUCFvexP^AM;!Y4S()ZPCzFaK`M4bXVt zueLb|&KqbFFQzA$Fg$td{K#-!)(0CK^N1F5u0pTfG7_qtw`%q9)_0IS`^3&jSIUVa zk6a)--p84aWpn;Cggfs5<44xhncPAR_V+w#(O($v15ytM@~Zh`N%5zTC1qu0k7Z$= zb^HW^RWLvR0`#I;tIi0MWbHflKp(hl?i%ONuHkT_z3DuBPT~GrF7{vYAJa)ay|nYR z4LN>{kPSlo7**f&@&vc>m3TdK!TENAD3rvj;C)5XN7-ZR%Q9(yh(^KnjTj~KYu(6~~}%2}vveCbuTHJfpg5}tbB@$~dp(ol&CHU{*5&xz( z2rO`2&!7SdgN3rv2geefI^~xQ&UFS@3AuKpRFmcIoeW_zMzcW)yJ~t??TqSWn~F>i zi`Z+8Tbr(d`bxe!sH}=mQBU?vbqeMFU<+^5!bsnv#Jy-z*;wKMlP}&} zJ+Wt>`9_uzeD`jNnp@KDEbWzxg1J?9p#aB`nc3SXGPQDak6%YoN;#m9SAIOOOOMASx-F)-o2D7HIGRqRey@+3VveBE!4#NthlG(;E%}^?tk_6|K7yCM=`i{ zdu7EFr5w)A0&XeqqtntM5AQt{a&dAI@ew&G!D3e;}jdA)_7q z^kThH5y}jsW?)H@lL|vr<6}PfO9m1^a?|YI8-=dT;4K;y{2ViZ7KCOxn;V3RIGa}P z#R|OQBYZIs;#&1Y@J}`5zi)r*9{&#Z+`78B!%>eMNTI+gBpuyf{?Dt;YLp0ce*5K&JGp2WU9>q%8DH@yml4 zSs~(xI-Zpjy-o2L;m~HLhq8BqY*AOm+6V5=cKOdK0NV2I*4DG$uWx!^DOt4li8|3d z$j#UDcah(ExlzSdTl{L{~vDY>;jUsMEV?HW1ditLN}hnJ3h@hEC;9)1KetdBu%nq(PrI=O*FkC{FgqP5F<27Ht0Y#v`hvVyU!EI{ zsL7%oV$zKJft?3m>SRCci22CzDKMB+BZXt(LBm~ecm;EQo_yn>ub@jJgu@K z&7w6+NxarWnfYaLOgJHeZbLV^M`HKBg&#<{~tfmfPljU}imp@N|Udk$qpHx=;HQ!X( zi^Pm{9vd1yHGEoQ6ZT{9jdsyTZ>6Um>w^bh3?JtY@06rBmb%KQyww`fi_so|@FZxI zQ3qVCP|$kpJp>NUFo0mKw^YeZGKYlt&k75rbGCLJ=t%+UC67+l?8RJ@7PJ<--KWu#kSXAE^ zt#o%uH#nexNaxT)mx9tEA>G{!sdP&tDBX>;(%m31BHazr?}ESjzxVt`z8OYj?z!je zSbOcY%~#s#t17E#vvZRUzUG#}6!!%}y*xS5nyGp@!nGfM~xebbN4Hf$YlFYEY~#b2xmn{XcorM%EjzBnf_ z1>dJIt_4=eJb90=AWjz z6vNbN@#Y$<-u@^piDo9~JB?pP1L{clEq+F$PdB3dLzF~p(1ENH3}S%Zdc=CfTg4+1 z{A<%r7=QzPo~xYddEe&g4~Umc|7#llV=NXjAG!Xiw*L9%2=V<6#V9=aV^hla5&2^O zUEP0HRDd1s*t{N`Q7Z`wkokUEb*iKlD@M97^;LiG(MD*XOvYV;Ex0+SxzJT}I)2(s z4%`yo;heRz$j=|tpklKx^yG`Co{JB5d_oe2$@-e<`YKYNjHaGVi>jWFjb4U^jb5p$ zUTMMl%DSoL>dK15tiW)8?`XRF#RYD-%2UA~_BuT^pkJmY-?LsnCHs!|UMgoaU&JZc zkpUUw8STK;VbEf;69ti`-YfjeEvPN{3#H?w;f*v`=sNSw2p9s_@SInL>z74?&C7m!-ICB=)ksO5t&RtJpK5xa(p5Dlokp^vWYz#AWXj z5QPYOi=QrwQ6^cdvKjVYj^V*XgK2J(3iryX_x^6K(@K4G^Z%)gwo#!%XlrsO8lGW< zJumbT#~^&)byRf>#2WMSR?k*J&+>NMvg7e8k(U*@C({H{rQhZlBFvE$4Ly09O%t!j zo`{)kMI24`e(xoo*~Eu(ya}3j_;a|?Avrb^Hia}zj{v2Yc891~4-ac1VyEvE$vG_? z37a!f3I)|=a=F(Bf2dzlZJK&Aii7Teu@k_#@Aw`zYft!t^Ef%e&r|)opZ@}az$8pG zQY@8E$(RWm64iPL`NsHu_jJEE0W%)55q1|v9> zFe(nf8D(CGu3mZYo2a$e74$~zYEfk&=>-AkvD_MSL(}}ob0&r<(Gc%6iYj6OhaS~9<&W^X9!GUqhau_36{zoIXzM)V#kI1(7m7{t z7-?ynM;klP=Ej&aNzI#gEmrpJ%Ce_a6cLeaJ}^4Zg25)%?uuVomQlOyU)(peL$b8- zfnZ46r+EIaI(i+TSM&e5V*mi*2YwoxK3Z7$IVacZhUCYzi;cZIaIA%aR-KF54|i(W z&x^u*zl}%%KkXz6>|)o};dsHn!vfFb;_lUY_awLgl;_?cbTLH@bLu#0S;ehC@O(IsCyKcpxmqGb6TB4Aw)q$%X#BF0Y89b?c|F1Xl;DWQs`1j=>=QI}& zXd`a@C}N*rt<$v8V9OQKckes*${z6CJa%ne6JbWvu?7!Zn{p-&hjL{ zq0%95B-J9lSF2OLRqwmh1%g&5fwz-3JoVGN-;*SIw!F=^bAqc)ERD|;ZLIE4X-q}y zH%3Cq7esj}6oDY*6o}7Yry?&(lmGfWbD>~o=TktM#cU|8!D_Y=_`~xtFQjp`_J0C9 zK%R^N?j3%u%@Ed$YSuU_6uR?HoX5zNI$j(8f!O@!KWSMCXhb(X!x*5C8&u@bCBF}% z(jI0ea>&X#;+L0?>6#bC5thp)QI2pZr=WqdCXR3P#yZKaq3eC9FDHs%*(jJH!GzQv z8(FkKW}F+1t6_dN!9h^KpbnbM8a5yQA{}BiQE7`6bb)YrF2eDv>&%)@#@QCWOt|%T z#B^fmwYj2YzLb)P&TVg8lyI}n>E#|)V$%;4c<-Z2su^281k|2@VeQmu5(`#n>o4EqmhIMtg&^udT8Jk>2wd3K>q11q;Jp zQ~L^26#wnMq8JZGWvH#^szLKU@7{p|&d(P;_kq^&4r$^>0=T`P|9dwB@-2w_C`43V z2CYk-$Pqb^M~E+-Lz=_CdbygYPQksF_z^C;m7dX-jR4H{a56W%eI)W0hH$4Jrp@H< z1lWZy;|32EE#Pc;u=&?mzVND{Xup!7a(@eSG>F1`%Po_#12DY~0HJ|wg^u z8JpUHZ-q&hNYh7=Q%@Cv(b6)mmydyhIyTaQ4&Q-3GJ(LJtUm(63d|wjhQ^lSMh3+8 zN&@n77DX0WZ>`5<1xAvvDY-90?Uy6Te0Qg9{8~IZG`q1Wv82-T4=Nn1Q1p|a(b74L zX!I{w9<%0g#FEDDp$)jv`Fk^}=;(YclQ?(P$(Fs^&b#SyWO^{D{pG`k{X)1>aP;)s{~c0iZfs=FQ%xEcSiZcRj69#Xyc{i3uJ`3ZxOU-gp!i-V~9(MoA|5x0f{^`BUs z6rj^oe%O8BUj1=&=TkzhBCQ0X zS468?39GNPh+df&6z7)~Ru>lJ2jEZEbf~M?-yvWNo6q2N%6MOGPm77RYz?(8T;Oc@ zZ&O{vSbc8e-rES4Z#>GU5!8px9}1h`^RNf5@Qw9+_0^Y?K}XxXlDgExb4!6Mz(jb# zG8y%p^lBCYUm|TVQ*I~`f^E(1`pj{H7Di+~JLZuEaLDfsVS2#5^4KrqpLCEcOMDJZ zMS(bRbJ=u^^xPq6wA?wm{wAw(HUZn%i7a_eeo*1d{Ox|7Rn}+fT#OS$u~elvtEsv1 zI0QP&hQ&j#qIV{#1!%4FmDj+nyWfG0flatm7bVg9!h2lUih@odlPmR0$k9QM3k{?);eB3$%iRbcgY&OpVEGMTaVq4 zX++@HQN!p7OXCSq-w8&JaGJaXKE-*mnr^UfCd1t5ryq!Y;>@5RuHNVFx=c{QP1!u7 zD;GU$^DjIN@#1u71tD!?rRu*6YK*u%%7r0qa%2+zP~;g2ao+8Lc*Uv_jsbrb7A>{l z$295-e`5g>&Iu@JPUQCjN6mM)SL?%>Fl(1@5r8Hna~sir6@Q%g;O6-tn(EuKb?P4Q_P=7KbPnRFUv%TuFj}u#YIu!P>LS?HpUhtz8uSz ziG1wv`#=I)^u$jhTLKw}-)^K`>pKedYp(;b?;#XIZm3Qh{++>8Toh#7zs$`~@Qjs3 zJm8)BM!$dwWR& zm{3GQlVF`U_Q&3P@zxx+M2|x4eT$|a!X~GDXmTcVqi-{l0aTdbSPK5Z(dJA(hHf297CvXK1=qIq9#nYg~_O-}#t6}Ho$ zX4%!ErfvON+V&8+Cy&!F!)_clmf%XdH>A@byCg|@)bwpZCOYBGD^|4)whEkHk=GSC z2>ejQv&1$i8o}JUzvs6d3=E8O7x!vre{Z@rG2lvc!+^u3yE*<#OE#+tuZ=P34gQeU z87Ka;+Ml4~xyR`<`?b>XJ^-{K( z?^U^t=RNmj_`R?NQU!@9n}f0lpW~CxR`<)g#^NTb%hR0@pVJdtpPK=RR-d&c{k!9(kc^w%&O48l zy5ZZs82%`c+g}97iLLeS)5QAvNWTse{OrWg3|UEo20zakn!M$dyjy~6k`eovOZ50N z7Md2|)AuAEjiv*9_3KS#U-6d(S3BW?K#pUM2S-+e;_rKVoOupRAhgv^`>3Ve~OIj)3bJqT}KM{XDd7vEZ&MjJs=3p8~=LB}#txDC?X*u$;S(Qq-ft zybLsXKr2G-1fC^jRu}1#g|=GDk*b)UmfqQzXW8{_Q1{*K<@mnOnPKnHvmbr(b+2}2 zG~P5e7h1_o4viJ+nn*u+y_-LWm7uX(JEz)U>A9+8u5B`V7M`xPnx`F68=qtYg27oo zgBfbqYQ$SVs~h9_(Fcf^EjBp2&Q2}lX}c!a{HQ^gt90~~-ArV!wfXII^HFnA`*%`O zrq^CNKVuWmm4_%F1GXWj?g-3FhC`2p$mnn;LBqqhxw2MXlq=yxpr2vH8qMouR3?;V zFpWR1{mSNw)Y14s+p`IDZ~h(x%eSnX&b~9%r2BgOl|SES+s@hs`ctK9yg#H4~q5 zOWdQuMfk}ah8-i>655j{5@vDC%F-(>hOFBRBaWzcUx0I>NMQGI&*(u~|1E>x_LK1p zf$e&i5kHLAhGjxQn{-@O|D zp%+1pr43X9=O4F+o7Wm0TRm_1wWiaL7q*8QkogOH%78)I0Jm=NR14O8TLf!X+9pf) z-EEro-u<{8t$elTJaRFuBMQ9u(89Yrx3Ac==czy5sHf~SShMcEKIho#I2muN%;(|d zzPi_7JDgg#BPF?4VG)L*S3Ms}xyeKiFwlmQ*Y-TVUn2mZ=^Yy}=iv^ATsj&kpELTEd;GA*hVvA$fVsGBHiY2)bO8jM z@PF>>EgVR!CPK0l(Kh&Mn}rC^p#9$`pnczVf;xgeaoTU63i3R=KB%E=s!Mk`0gZR- z+g>6cX{AFbxYwdpjj^e?=TMR8xv0G7qrt{F z!pNmvSd_vxGaJhx5mdZhm`BSYQ6fISlL>;OL<|v+oz_ z^5X8^$oyW8^5jCj#~!jQ2{xV<4fpnL=I6cC)N}=!ij)4z;RHZ%^YH##>}>V?V;e7F zg{w6Ku8(su=kma^Q;f7F+tSDT@(4P!hEF!M>ExgU*|~Xw1KDag_A}x)IxO!(rroOo z?dqPWj2Q26rii2?dB(KA;a%dGyd=n)d~#saHvq}1LB#~7u~?-xf4ltwyLGWFiSq$s zFRk~FZh)|c7D#&5FP_%p<8Qzt?yN>PcW_RdHPY(TSx+kp*hOvBY1KI|n5Bx6bN-B0 zkNoJSk|~yv`GJ`LjeQ9;Om>AD{Y8E=CiKz_AAZxnIAOmN-R2}AOD|AC9$wQ;4IR=m zA?}?Aqfa`BwP#O}R9%`=@pYb0$Z-|3O;GsybtP{-sioR|ng_7X=}3x(OsX^Y z-}c?Wj3;CM=)ZI0ELnIp^!#2MCkQQ>d{6ZXq&oo*k{g?J@*y^(Wj_|eWQWl8gjAW# zy`FrT(QEY$D9_g8cZ-<`3jMiatlSKd0Uab*7IC+O$waxQ1PCZGPMtvW!m?%437v;( zs1T^EXH3VMAw9;uQZkcrMp}CM!q#{MrC@D+ef{JDP_m8&o6)v81Jgx-BDPN}-WVL( zi2{76Ho4*XX=#g!stB24u}=IRTTyWVsQ|z+mO_EZ{Nhx^>5xi^?fR@5yvD>B*w4L* zO27HDPqqJD^xF|s1eBzTe7P4Ikbw~H4Gi^SIBrn6hE5hX3mL~!J=(0&%6HxjU;FR! z+I@A#=`DUGoL%{be$hN=A0}RT2X}ML`lN1~z7}iz7KrHa#6M3yY0+_57!V_7XJy^a zMyh?x(dW^RK`qqr)%M_o=?+c!fRHh6g>ZmkUcRln` zPqk}{zgQ(-J(OuQ7)Rz@&$X)S``Ma`r2vVgzY^vQ$w#h_$uQ3OwCH#f=t?o9=<$kP zbIoynH&QJrHcw0_p^K8v(W=O|Cw%{{I;Wx-JX)Nu%77oJ!)&z5$M$v#Uo1mQiOVRK zJPeVuUz%}Cx|+_k6v9Q!9WG_;Pgjww!4=%XB}K3L2~{Vz*R3XH*@$+?CokjVMNe}r zwLcmG6O9`z{CKJEa>{uo-0~AEuzq`Tepg!#h-qwfMr;6Hm}QJc!%=clbySdCGd4Ua zpM6Il;8Nz57bFGzCa;@Vcb%jLKy>Ijga?FF97J!mAf}!^G6TjpRYMO$e4z_|BM({b zUQ<#@%+GleM9RpBt~FLc>-8aqX)^#E>%rb~5=YlRczcAmtVH^vx2PoLxU1?|a{#9I zrtoB~{263(&=5+LHX|PWiJ-PIM@?4K&509!a&j`18s>X~0Tm){#uTj;Vxgh}d1@>le0H}?vEGFM z8(Utq+Lo}Pnn6XPdh+|C_20XdTRd>_=1PGl)g zp|r1`t!&30#Oe9C+{((NyY+3Lg?*e`C%)TWJ=bF^E5Ka>@5}_RAjx$RA*a6J-0eKc znFqg?4J6hOgo59^nMJ@6@+%ka=t8Ack3a*Ad)=u_pzMC@-7PS|^w+ZacEd*Iy-5Oa zyZL3y_oAgKc&aG#Y6JDyJpaunqroBgkGU;gTHZ9XT#LZ6wU<`zvyl;5R69~nntMLo zqY6NDohIE+4K?)(Z!%}j4C>^S!1c4dP(GV)5qy!`@~i#1b_qU5x>+(b@THwUnC7Db zNI#%=EQn{9-sFaS8x5Xn>q{g2oYdGL*0dLY8Vi|J0R#&$6rl8s!X#^_!ul6-?tDu- zoqA(Qp0V;2zH z=-Qw03 z7%dmw4{wsDt+YcRP0L%4YA4zqXYJmghVhz@J)3KOC!MUPY|z(3h9#ptF8)>*r_@oy zESyI_kM6K#|I-t%9SEq5qxB%(m`RcBg`w-@(3_H8ueram01Pv>ODB7G#fC5RS5ywL z^90!gt05+dydl@ZT#hidPVr1PCoX>;Zv-zH+tTg9R?|T;5jz0g=ebAsvn0@}FR7;o z>d8ecY%4hamrICip3ti4JZqBFPW0ypi~zc*mE6y*V7(%^Y$1pOg8npwCG_*5WAxuj zoxTaySV$J$OlnUH-As)~wO)q+np{ayt-lG|?Ld@|!`F=W>@eTR$hAJ#pM$eQn?0jR z5{H1^vXEfKa`o~U>GdRTufML{2O%z5?}75XuD|fEoj-eW*|#yyO!dOPGjZ6g`K(SV z^C?SNfr?x^*`fh*L2yywen!oZOcv$hBiIGcLV@Nt`Np@-p2>-0dHQhVB{ zwQ8SJtM^v(Xt?ITyV=Pe{x_Dp@2%eCOocI!m^Setusxgth(Q#hRyRP*m|B9cGmONc z(lka!JJU$6;`M5%(P~5*oS~45c>J1d{d&0=@b&f3^Ug^A{EeTX)T$XMB9DW=`V$81 z3p`@C`kbj1Lh1DhN`WsxD@_HFvj!nK5XNA{j`YAyTQ2n{}( zezgJQVJ?%RWq17gBCbctz1npN*Q?)|GxTr8-%PU621O~sdBRAZe0;~D5H|7haj}*7 zi?=^ivjo!G$_+aOys&h@kVi?yz)r<3dJwk)s;tmQx2?rBxAmr7o6-?vK~3SLO*1p2 zha zP=l*JSbfz>5Ye%y&eakg3YC3(FNvPr*o6$H$kW0cy)lw0jbsk*xnDKp*&}|P=q8ZV z<@i`EpKYN@qhZu&3=p8dbm~wqv9MN)n|SI8e}c(Z&av|PD_a}pA_a(I;#y?3d04=v z@0&5x?tIO1=6kc&%cUYJURzZl5$bRA;JBxH>8W#Ge87U=Zj(RxT29?B zvn%HR$J3fW_n-2bYxy2G1_WwZMffVA%9gZx)^&c-yma%S8AZA4-j(*NM>YFIXDlB% z96In2aM;67hwQ5i!n63^qHy(`x{WgsDe;X&$DV}~S6k-gECB&8S~jaVdYscNg$D(( zNeh;NhsvnPI9Y(HH@aE0xJ54wtsDExyMSA>2tm)Wia3{jwwrZ2$vu76GSD=#xKXh! zRsHmMyYQBDBkwMU^?(49zhP&vuE%EHl}o}Th-XBX_&ETUQQvIHcull4d1cI_0C(1t zpsh6%7;bL+{*AkQ#j|`Dv&?z&e{Vg&thqWkFSNr!KL>l@Gwl5tykOvq=G0eyb88d6 ze2*!7@WXQ0){!YZB{g)16ckDhUha~+XzY=38uYZ^;D~cH@#v+V#I~qqL_v{-8D5i0 ze_THaWKmL^G2ji~3`i*Xc=*`V63g*JiDj(#>o>9>xq zzXZVncX$8D!oUT*{$m@Z=LWM88HRbLC74 zJZ38-m=t7nMTY@QM^@%oV~IIH#=6W4HG9v1+aulwUXR*??_(YSna0IG*%r4mcziAK z8Qam3xYDP?Xe7@X3I0GWM;~Gd+HvB`#^st-?*nvpK$Mu@mXe|Q-rt<@8j?^>_zkwu z*3-BwuFu9OmD$s7=48rtOJ{deC$BuFe&+KcB*G)mgBW7dW8KYY{CW@*V!%J7#-A^6 zGZ7IHIR|ILl|FE08oA)E9T&D-A1&eDX}G?7sw4F8%?FM^HOPN+{7c>Z9Z@3Q#eopm#R5ISYl{kYJJTRzFrQGYEk?LK0N=8<|D7wAAYrE zxV18>Gi^L{bGQ^+O!eTZ4p~oWdIoX^+Mq;K0%Y+6;%Wmf1r@P&bd!2+RY%4p$`TG4 z>`f8N$k++SbbjodvmPrO`11lxQ47U|D?ONN5?v7auQLWdi>a+iUqFx2 z=6mp$gnh<1S!!C3yy%NQbqM~bRhrXJolIN-Wh@CIsc-w%Vo3@1ta&1`aVh}{s&(iE zgU~Uo&rf&UZf@YR$p_(5yT+=XQ1&W2%l?iPN5>=70qM%LQot%?Avw2 z-)V7Q=p0(zx7U21x|uF@muYqtAyS~Gmdj|PIs%d4r6)qNfR(8~q6a3N&fr|#uk&O| z$4?JIn9qZ~oBCKxq+2r;yAguehy-Wzuku%_T;Vtjmq^{p**uY~Bm^*c* zZWmlj1I}a^i9y88k|v~xF@J||^n;I{VUKl~KvwxTmC;#;O#Pg6O@d-*1+Nx&&YT*x zGv%qJ^KLCeOtJdREK?l4m+jDNDZ5fZ!|d8v9!ZiVIdqxzeA#!k{)HkK4-#L&NRskkQ}#o_+s{BupfR{=D*`Bm^r z%f)vZgI1Z;HR19V8=cK(0079DS{QSHPq9D~k-hc3tGb6u1@tg4Kg$TAX z%4pt*z2%jLH?3jfo6p^Eoo`qa#A(l{QB9ugh{L@qV2B+VM_gXQKxZj<(xPXoQY%F} z=nEf62~y0}gJ3#9p$xGFIY@C}bnV!_^REc+k<-0@z3HarcUexv^3^|x;uU$LmXIGS z-hm{WK3-@kc>X;<(KuYIn?ef$7O@&3f;n#rr9KPph&pG@DR{pR1~L z9N(;nd7~%^+aJx)OWV#=-tND@Jsc`Dc@<{ynngeRhfK#yU<#ZE1>9A_ajI>LJ7CU* zZI0(T?oE&rBHTO{kiTEw4h(3A|0%$s03zyt!f!~H3;|!+=KaNv0?^uzPn&kff5P=E zu}A&|H;)Ii8AdJEGg{5(|Y zg%oZlnrcjs+IwcVTG1dWbucAgnj!(g9L_}s3W)mEDy@U4+ZNiFDE{kYtkl-zi>+~a zjb~-ynin0fEmEuBNv5l}OTsTCJz|ryZQNvn`r>}wZ)sW?y5GQA<9PRYum~`AT{}E| zZ+RsE0u&#AM#^UO1nO4?gVN{f3h|@Tf%xU>`D2Y7TWm!_VJPC~0eD%tj z@nvehA_IX+{pXu(G67i+*&?^V?+gToPTV?PbQu<-mL1q7NiQ>uaD`5zrBR=0G!Bl} z8Q770R%$8R>aXi%bMvhos9-3^K@7&SJlcQ7O)v@z3FG@Csh#!Zes0kh?_a{^t$->7 zZrf@!wkbwUbLC0ixqiw`R~31!4WC<^SX?Bj=wb3b`3co=Sknj`kgUdmcw zv3AJ;sq+Sr0iJY=N`_hslqB!xqnk0!PGl;lMOm{C0lhj7d>3j0~b;QVN^^+GGs}9T@M!!y9L(>M8Hf$;S147I=0s^mC;* z4zFSV+H(c{!JP~vnu`H54hL%?xf+@bN@mRT2z8Z#OHQf0u-$+yi!@mb3k zzM2e4Pa%aA4XBBI@GZ00nW>~CqLeeRQ&tX^B zfk`mkpNw&FGMO%$1tiMUcQuY(iXuCH*^wi6g~`dy+j7$}rQ4D^-N1;+X&sf1FKAWj zI%=rcCZI}7L`lz}4P_nRdLB!!Ck)WprE)`RHn`{hs#+u+zf0{rOwRiT`+w-ZYH^=V zv?!Z$jd#G!A!&(^cF1+0SfwLrp;t&c%FQIqSbq=545c{y+U$-Ju~C25JD{1VS8FO| zokL7c+yUM7e)^_N_UD)y2Vl?`{9fbboB~>B4lt~`?q~Pj7ED6xml}9=un$O|Jf4xN z?J;>U?oD_Y z_%2AvsIi9lVol|;vjh&7YJEeen}-pAO9bw?;4pdv{+}f; z1?*+``?Zg<0D)lxyQnLV4E`XGTM(vwF1A@Z#va9oZFtp*jQQ-t=~jAzW^~+sW9c$P zvysD5haoMdsdQN`Y`cUDmGjB+FRV%;-8Mrgv13Army24m%ZT1W>Rdp*4h=2?5Wzp;RgJR%HzFW1rc`)po@-#gYdpMI?JYM);%Yrebq zf;RpBG}62JV*E{+=*{gq^)~{0g}i})Q#I9g9nBYHU+_4sI$ClkaA zXj+ftq5XudH0$1RPwQ0GNdR->9%2m#`09J05;!zxh)fE}xQj%+LqjdVfx_$GKWkb; z{$r!(;(=v^dM@M-a$X4EulB?XY+GjQlq1_c|Lim z^(AU)Iaz`#l9o!d7mtdzQeo#GmYJfc><5A=$@SgOYxwuS9+cH(-mPmpX8JE?oDXOp zXSh2qio(*)%XszO&PpyE7e9)g?xePa`xRZOc3vupWflf&03mG zy-a$`vNVOU(pWkUj@;%eeJ{ur$p-QE?P|Zl6&w;9tHN>=%FB*l0t6!s%{6I9^hytYaTcAoOVIZM)6O(HOPlf{-t|ue zBUyC`p?>KQ<$BXjDM~FB)VJ*C@y)o^?Y6LU)zdg1&Lu_y;KnM_F_ngFy%!^5@N5sG zaQ@KtBnY+HC0}TjG4>FV6V~bELi-qlM3vd&?cowkXq9f`!>E~pkVQu?y|TbX4}o$e zne&+JCncf7{5T~Fr1a41Tvg^MQHyKIjg7-ERKiOWpO}4ClhubFdtZWN6oJvL>&vIq z^w&{D0=K+Ei2Ek(?w}U@<(UP^@=EDZ2L{rER;ujQb3K9&o$9VQm`p>@B zMYW?YQGRrImB=@F&aURNCgT5X5D(S5(g*)?(SNT!n=wcsZbTLx4lTV}e||=#OPa2M zY4ZnX>3a(MOQ)qp1)5D78un9R^7+OQhkz9U?3G;R`bozWiCIGE_VXHRA~gs-t)fJ9 z-esUgFu_1}Fy)kHGR7`#TLi%$WAo*H;mwD7~UA z(`k6OT13s3*1V$cm?(O8afXGSl&3a~6liDu8vKcLk6vbC@wi#J_4i2wqoY~t;Q;G? zbEC!a$B&W`YT_&(Mr0CGxg8Gh)~R@%FMDCX*Lq2O96N$Yl#_j)A03kD2;Gvy(jIwx zU*;ZvQNF&E8p*ZQ;W`YkRcieCD-3vhv*RwA>t;-Lyw!V)mp#R)eN-YcbXsG-_{s5g zu5nAj&!3#HH=K=_!Vw#}{L|9X0IJ{qTB0$DTFS(6|-C>m}kkum6Cz0a6v`NyS8p}{d%%zsr>bF1x7rWcF{Fnwdip+ zKDaGJ;83T;(e!L)UL!%aQA+OVQ5?I5$Z=Pz>C=xF(?PyhovgqQwRh;gY8tWB45ke& zTkk%)Tq8d1XC;fCuJiTLUqLv&qTSD&uFEXI?%5aQXobYM z6KlTWm+OkNZ)-PQBDj72o|xyeGO&-;3BXn&%a^z)fPwab3K_HiwFCla_m!EQ=TtZd z_Y$!I4S+=KeLaFoG0W0Mk0pg|{>IO4*LKoCnKJ*-e`j<*C!cK2-PUo$A0$vZM%Beu%FLG+11`nceCqo(~aqz^G{ zlC|>}^jitevuwg}d{<$$*w>cn9g4Z=rW{t1(YX`P#H2EFah)rh9h7A`{%sin*8znE z@4DZQ8_@90fw~{&cpUB1J&-b*GOa@yE~!kD__g*q5{j0JIQFJI+e=Q=X(J}dC=A{4 zq6kGXvnEy+J?#pEsg5s6Wm>9@MO-XZm+Lqi2@Cy1k@UHw=v5P>QnU=W1maoa5V=i( z7@r^nW6!c=eM_A_KMVzX;5ENv142Z573y21e9UJ@EV?MrIV~6+- z_^vm`XuRT(&3ZY5P!8FTX#h;l2+y496T9+OqO=IPp(JQyEi)Ss}e zdrb0Tm(vs`M1A&`85V;Vw&_(vPf0BIosaOhh^tX~^|kQpXZShYnxf)L%()g%!D{RC z+LESPi{1G~=XH&jqo@gqNgc?A#U%k2u{FTV-V}1-HIb-^V?*M1_~`$YkOPzB+0FH^ zF95#^kT7Sdd8X5}Y0lfR&=NV1L7fx||J;_#oFH!imN5O@6vqT#$gm#NVN0Wbk!hoI zanw|1JyUB4X_XkF^c;HiMb~2%L=qunQ2!oIoSm`WrC1b+49`dgviKasLD%f0{8Nkf zB_k}w;gnbD_Oe_|f@gpJdM*TmcV{LPRrsl)?dJ(>q+swyIk$h2nhZL0OC2Bo6+K}! zln~x58}B!j5|9!$KJm}V$+>r{*&c{K(fY>tWKA0`^;Qo))F1I4%Jywbo%{W>J@7vJ zd&&Oe@3b(4zV)ucFFgz44i4_PvD*tf2rFTiq zeyi=gPm8l~bwzdV*U$JzYObES6X)X5+gJScBn%0$au8Z48;mCysRpJdMTU`GT6bgnMUUsD#s_)SAcH~A`TaM zI+`rI5rp-0J7q(I_O1AZrQDSD(}nmtCNx3QJ zvva5ha`=m9#*2TBJ$nLO%q4`F6OYBK_}guwV+xRB6hQFxzhYz}d%H!a#M1Z9A`A7Xl{> zDwbs7&KF<;s=)$y=H9dGnF*!3hZker>!QdDH!BNuBA3P+8KMhVH>OhvzMh9sBiD87 zY&y4Np!N&wrh*E8kUSV(xdeBzg{zLjFPtc`&@M2HShA) ziPwqbc;8MFxQZNZ!LmqT-p;3q!&zIBCAz>QcLoOH2u~VxR}-4~fYE2>z?CzqmT64W zepDzdpc1Sv(EAVh4xGdU7^0-x-}P?z=C<(dCL}rYp(U)0r`Ms0mf6o*cj>fsuw0{?fyziD6G6WKPk6Vb_M?vG+Ik7s~t5Z z8ouaz4BL$`{Hmu%_e|-Vw-%I}rX^~h3I1Anzz3Lrysry&MNz}*eSy3(C@3i6$L!yO zY8Xb|pxNo(+oQi9!ta4_U3mL=%|;fe0Bj`JM99&rMWFI*MW2Qfn^5t}oM`p)60I)p zWDk>VQs7xnBq%jnV28a$7kJU`1zLh7shI+E-EyAG>v@XM}cew>R)ktHSP-9_shSlwHV0kT($6Mw9)yx&R5^Zw(W zl|gQ*z-;^K104N;2r0t@%($VUAte|&{F%9et>NGOAoqjyo*aw2t-vSUs`b3YSIcFc z{Gj3g)>a>wP7^Dx#XsiH;Ly!9ongh6JEu`W>Tw)5eRuMUIvY0R^*fui)!kuFALg;| zY<`nC%u?tgRx$qsXy;9cOCo0xWalOBkNjR~{HDGN|9w}MITvw+>31W3tvBxc<^B#B z$JsjpI}jJdxmRz1v90}y*5t(Oo}?aUHE(c+J#a6V=S4*he`T7r2v}~HQmUAI6{t~a zBA+)=o%z(ENV0kX0tIO^X^Ep3864yDT2;SgL0W)!p%VrI#Ite>jLpRV2#0~E@0&lS zZ+7Y(@HZa8U(R6|zH@9~OlX^oX(NjJmOWq*&#BvTqg&r zZ5|<0ZzDr)C@`n7JE1=EMLHHI<|r$kIEfWgxgd4M+C&_%MtG|-^5F^_*M{fi#7^*3e6i2=GH*5#EAi9UjXzI`-cMWW zAndZR7b#8vtd&qa@uN4YfZmCbd4zP1DlfjQCL)L|3|wK(C5UXYYAP|BmQ3!PJ?G|Z za=MqknpY%xhj?kQ8XnZI_ojX)0fW z8!!N5{%ra0E8pg{aCroD;{qJ%F3;b|&RJv3>fIU&giRQdnyW(Bk2fzxiMv~8shs2bNkT5`7V zv?hGcdB`5`<2r!-qzzALm3i{>B0zSub#Z8+C8R zrf>Hb^$YyYgecvQTh9in8~ZBPX=#X!WN?&GJkLE>m)`~)gnF=2cGfu)dNTxdo+s>I zWUOC_Xho1&Y04QgP=W?&P5Q*Qkhug)5u_+KfSfj99|tw{XD4xT9SYpgKX-u! z9RmZeDg;3K!Fu3O+`k##x7m(aZ~%kMXLUSi@WH<;FI^1%z8C z0*;{TFN*5fS0)+DSNgzszI1#x=w9 ztftj_8AUxi^LlTpwdI=d&VhrB&ux%TV6j1lSAF;Tzxjq!8Kn0PNlbCg!)&|3$7sZs{yEgT%MpAtKE z#xY|04yCjoH%!-7w_lBgm!z9Y>7W8I-_<=@eDfM;VcTFre5u<; zCgv4JZ*81U%M>}noil~*@2M*`zlc1k?xKcH%CNxTJoPiW9JeDTSwRZ7E=jEinMsW3 zg%*-C_zTH<3epTK(mK3k{szVSBB+)b8K2P3IitVa&zyWDaw36SpX48V_IYGx zrm}Q$USt$m>Dwr4SbfKOZlndXK$JdoIX(*U-v1Xjc+pk8W|#^`pheV ztW2tPUI|+I&YMsw?-ABaQCGKCeb2)2pQ2UKsBo5Xm)-uILjt@mm1x@(kYAMpu0a3+ z?ptP!T9U#$I19;{AfH7Yedk#Wj%-}qkRam7iKElsFGh-FL{BfHiKg)Of zt63{%EOnz3U$Tt;jwz`3ER-64)T+ms^38u+38wj)DR)>rMYX;S6J0#2+m!YsjE0n% zX&g&!iy|aN0@V*SNaNGU%tUfbTMPbAzQ1k$gEe}{HI<|vxgr&Fma;laJUS=+h~Kpo z8Biv)F^B_z%U$ zR#@k^MglBZ^*2(V7U(3CQ;DZ!bCjQDsA;rN?Om}H>}R;O>}xl@R(e`QBbZ1XH!E^- zJC~2?Ne6y$E z3qVxJ!;SVsvS`HmE?}ryUkl}hoPS~)@NbL~|K^-WZ%GiQo6xk}tEGP-;_*{M9-=R& zZ?Nf%t96|Ib%oq)$^(zs0Wv;kQ*-b-e<> znbTw{cb=BkUqW`*Ttt|@r^E09mLEp0RSig};0hSAsr$c^Y!8w}&7e1rFa^bA_pXH| z(Zf?Es!C}Jw<8c@;vdjDy^&LP2ii1}c35OD!>-e=)663!qb5KUQT|$}b@W2)<%pC$ zGG*2;&3UcO#hA&w)1Vs(W)^5obG@7GB44P6^<`Zw zx&!5OUkr9`xf1>GISs74QApXnegPiIv8KJ?tA`o6+wMEI27TZxW7GKgGPxe>mt#K~ z2FYf10+&{botjE;&$3d4XDW{97q9Zru};}mcxm-jD+fxPmP@E6_tGR8`hS@E>Zq)? z?rXXmq>=9K5_srtk!}Q$Zj^fHknV12k?!tp=|;LkI=+MNz4!P1j3INS=CWDtMJ$Bj7HM3CpOktFRk-4@78-cdIbr+1F9O* z@H$h6Ulzzv*jp2=i)7XkoZ;l*Br{g)Noaf=fV1Z00s2lGzgzFY8zOn$c#VW#G*$`T z69z_HBQR)|=^-^UMdUw}qb7o#qs7&DtTKdSUyal!wK1q-pRe_6vuw({Y&KVZg)Ro1 z9{QLF3|};${a=2{`fv_rmMrO z?0!xNT%eurG8 z!6m;$*-hmv%ZFv*%fwbgiz00%)FPwa_SJ6m!N>hP)KKUl4e$th8a{15;M)w}QWE+2 zd+?6`?rcj2zRKWA%e~VmK3rv*Q>GYMY(97=5JOFlI>1{*SV<1JCg?Tm5H~L5Ah-6I z%e&_FG`aiM!|2-1`_G7-f!4K}_BP9goUacayA+(a1cPx6a}ca9Dqz4xY^Gz(`08VM zEN_%$A@1q@Ft#{e|Ngj4W?kuA_+bO|f2#nb5^C`6lZ7rr{b`Afe*G1obWeyQ^k8!< z9@i9Qzlc5ssp%&`hzMfsNx{J3k;Eq@WSx_iE*89C4u2Y#0=6rQBE&{9ORG1_>lKk! zL^2iILWwdxvV+KEcYUxfg$#>5nSBI8b4NQHmSUYKoZwH}~;f=8r;5*TfLFCcz?X3c9v++38kpKgq9@!ac`aD~+0kC*M zCfhb6trxW*tDDVW(>CLp{^M%e>CdNz+ybn;b<8g-(L#wwC|J!2YDkuC)Y&5plF4RT zCP2?qHYb)0)zcr{Qr8bO2e|!M|6im8Sx{6|Q=H%JFbKP@r3lAmHsI=Ah;NKJ{6H_h z2h0jJCYW%7K;^&)U7)jGl@2RZW-jop=QS^{u;cI#pU1(?8$40nlDca`J%l?oYN7E< zA!)e*7dAacRSk7rRejyJ1Iq3VcTkAFwfOX*{DHJT;=f?Gu{?-@GBH%tia=9@bcUdY z;mu?-V#}y5s^#r7A<`~2Vy52SfQB^Fx;@72(j(!!Mla+N$zvX)!WnExW z`0k0LoN~YSKVP1XQ>_(8!&hG(P#VU>oY@Z6uzSBo8h>GVS$9e-2Eyso(^HN)*wbc*JL&mk6?!&6=Y-wXrHGI|q47T8j zd|e=UAvhz%A8C^f2JG6b>FcO9@t@FERbSp;GYC|?h{9isu+a)?x^bjW4h)vS$w68l+w+{rn=BOcX6B#eR<@g+TZ zLZlKUCpSrMq8d_8AOa7IPc&sLvm#bR98MFhwHxv7ytuT!2?mKoKKi{*HbJ z80bFTBZCUfH&k2XHn}E|2$}w$GW@E5fM_Ye15Z07+@3pa*j`jZ?)3Ll?a$Z#!6*k= zMihUTp%%zAX5N=AwjeE5xIFbS8(K3i5syZQaWHsoi*})5cPW33##5#$*-}yB;uR^) zCTq@ib7dV+Y%-{YzjHuWlO?SOD3+uwB6peCo_dMwhHw~jp7wqKXMX%Cb*#>LKA!S@ zXubji)nka)Pn@bP;cLgjuRg* zx+(mS-s=xlz9m-*y&U}+Yd8yqV5o<{4iMGb*u57C`qNz>jZM*y1o<{4YAOSAW@KgJ1EL#T11x@1h>Ni@MT_qx3Lu(6 z0mV9Cp{`)QZaQ6OJd1BY{U5W)CV)az7IBI?>^8RsZjRW-fr8wq!xG|#cdO}?qp?5kY5}Ej*o5dF{zg6CWo=UGShWa2KXgJ zeJn`&@vjyjJpHlBYg~S&FUL&rowj~jS>&(|WMeG{RurgY@unm;8kh)r=0%VBLvu*t z{5wYFqxGk)Sw%)+9mv*;&TkX#yx!J${vIM9>K4iR!>r}C5K2%F>JorC937d66aVWl z%>IT6Qu*=iNAfU$FZV#erM4eNunpFJSAisi&WN7uC-*im(-emve51jK3; zu?Rwe&&$9fu?SRz`E<;yg3Q=5Gia}FWfPs|R9I|j7x*se0<*GWDgJhBQe z3+5AU7vDhFDQZXU|0A#y>@XOOtSh7Kmy7IY|MtA2Q*|)^Po}SVHE~-C=CmO9J&}g+ zwQHq!5-a$KM%0HZ244Xi`@Tss7bXWG2LnkXP^KQUZ_m!%6l}{d#L6(j=&WC#+f+qk zK;(|W7K@dFA(omy&gX2ck6_$Xqwb3!f<{U1-0ZIqIK?&J;eU^FGq}Gc5NS`9 z?`4T>tjf4_|K2NFDci@`HOnvgkjKrqwwj_7m_vJ6%GA6V0_G~F-))G4xRVq49JQ=2{JT(% z@3YhPhrK*j;moI@=?UAP@0!_oOWh&?-`?o9Hr0&~Gm036o+k;4rO_azcb^|`3f5qr zeh&%0>>gx2jcd5Q%q55fw!MtLT(11|foC{aK_40)yV;bLp)-_$-SCmoLE_aLQNL}& zP|@m*wCC)>%#_uxOoxUefywac3)IxNUvVq&Iqjc&CdcfA zpc~*K2Xzu2eJ5skofAEy8I73pek7tX64_45wSwzoMrWn)#)M7L2G#M0jUFtJ1!X4| zvgOPO9003;dn+|9gS0jI@vWDpUJqv5yHa}aFPEpDrMqDqSVsR@`U3S&j0nEQg&1E+ z{??1U+upKbq2n^^i@C;vpo$Eb#Ym7kFOofaPG>&10rUG0s!f)MQgp?!_sFl@po72|C~(Q{gX7%x+D^by1NQDg21PCodk!ceKt*^6o6Uh{zw~EBZ-m z$jm6lGPc@Kr$BBbwhNEV63p>@Yin*D)I~fk(6op2R~(vWg(CiI60;HqTC@vTaq?e& z11thy6N6_cz!2$&vBNN|H@85ekN#6#)whNOka~&^OZnBu(k+q6^JN>ZFwXn`uh=pt9nFVZ3P{nZt-$j7*ZT!Zu@f#v&vf1W%>+wfEL;1zxv$Yv|C6P zorQh8M2p;>`B885q=c&cl;gGASRer0sGDu^!4mO?!CG;Rf07q(ro1FW#ifPjQ&QJs zG<+1&Cv30vF<9@81QqYKRj(X_#@4E8M>0Vqk~)k~iRx;{9LPZEY`+%2BH234wS-B= zF31-s4MuWFvR0-Q^BtUp%A-|Suk2+l1Hk$X$0biiVzVvW--BymGX3Zi&&WN_m9I*l z7D#(yK zDB?#*5c6)zQ2EkK>cv9g4vhTn+|E!78NZVmx~#o&VP!#3m5Q`dkD<>YsS?tnY+2-} z%lCqRlRjd`H>bTP*G*aTdA{>ipXkRZDjFlM?*IICTQU#x*$)QmJtIao^1r;r_lXMQ z8z1bKly>F)hmxwrCFK`lCBWnYv?k7`l+g7A|CkP^R@{uzp7FqTwONH%Qo=yaB|n!? zPWcWt&^+|Oy``#v?8Djg=`*YI!h&WL=@%p%7t=vAwRX^&d{1jng@2p#6nh~k>ghM; z-b2#zJI`A-I;@Cavwbo7(b3wf`UQCOf#4AvG~fJu6~~@V!=`ILqHIgrET0{AiwvHN z8e=c?K(r$-knso7y*>XZ;q$XNC87J8m&?(#oGo9*v&EJ;5@5N9BNNh3+5d~;!}?aopUlxeX`47EH*LEQZMI!G%$&rX z3VnwDwt7Q6pC?fld6IQBzW}@6*#PR-`G}jlLv#4}g4=4;589vM9{=1D^nI&9CPGX2 z&AiP=LqF{RQKuXRx zSLJM!W?%o%smqLCh4W=!eMo#wi;TYR%i|3-et=1-D^D!Qjl9F__3XX|_#~=mlHtb= zFPm7vXZg<8^;B}J4~%pFR<=U%y+UnEfr;*&>!+3bUlWlTOUq1nv2tPgvC&vlG9q8s z8AE@s2Zf|1#l7CMUZWb9C)|iv_$s@G{(A@?7}Ueeu!ky1ES(~aM}=!dW$y%2Laeu%2wnrQMzPg(7w+A}0z4lM@v_E;{(-Mva(DK*yZ)C4@ z_S0zP8YU5dhV2!tDoCC7KbPb6UUiku(AMNysc9U=Bdax{%uLxR(u#DjYVxhxLi3h_= z#G5cRYwq*Wk7bPHCfO`WMRnO2ieL+Cp0fjA%|Owpqt7^A#yN1=Pp|hTY8`;$Bo@s7 zdIDb6RAnfB?Gztbc`D8qwYf~IEj=m>td1uLO0Os}QZYHkV)ZYIch_OZ6BdSLzOF}$ zxPy@(tSuq(cN++Zc^&kxict)Nx2RSB0^e&gyP+Wa;~Fix^Hl-C$swquO}Lv} zfde*hm`S}fY2Qj9xv=)vuXy|KL=F^)hCYGBTeS)2-;p;|c{{kcO~EV@`=hXh?7h_A z8@=I=r|B%p#)&Gh^~#qA5pVB(nh9p{L)U$7 z2^JTC`EwH=VD{Xy5FQ`mqH??lhhA-#s3xcp8*FzBr#vsYk60S1SyKOxR)58XJV^u^ z>Be6LVopjO;MkavWjh<`$~hfMSlmyT%kk#;xi^%b>_?yFO@-$>FkaoXPo}<^TpL=| zuC(&_V!13&#>MizHhNF;&6n(n&=X8!#bgG@huYKptYr-Y9Sz-WBwE4PV*wXs>kbUR zk9#Vb{&MzR;hzGG3NGJscKIY@>IOLzG2Hj~|NB&c-g^}v%40ike@~BHkiq+=Y4=3X zW2mlC`JW82M+{^CD@(Mq{`Na%hV#alKP9nD>Q(1Gx}nxN1&(qLIqSZxq1e!6!tOpX zFA2^GFJELh$e`ZfVTPjFCCyYf3|{x@^@=DS59XD$I7u;;`KS33|3i= zl6Iu9tt-t(N0gS;1X1LMdty$;s?z77;rzVLE(uFL{`egO7uTt(YPn1+JK1}omB{7U z=lU5M6uGHG1RCGZT*zNZ`0%B6h<_IW0V6h~kIb)A7ADwAq3X%d(4zh3$cos$W8Iz| zA4Lg<+z(S03;1Eh&|-c9&u+?o`AtSi%EAI!OaPX~{ry$3dg0WZkdV5I`G+G62Rf`( z*H5%6T3^e^HzCw;!wSM)I;Q+BQXKR&_<# z+|POjmABGMl8TnT6U2j?1mE7Z7nJX4eL1hA6r4L*Qc+W1q^21#=ik8pc$1@_hHDhCYx+p#+U3(bw@!`<=Vp#pyZ5s7^ zcb8z!;^q3P>+Tf)X*tGfu1u+=LF8pYc|4iqv9}Rt zHp(4aEP%bnhWOD3Qvxv`j4=~@;2VM$no!4=*Tg5+Z9p}Jfdq*VT&)zb`sw0#^pM<_ zD*sJN@}0;<2@Jpag&Ep>6)F@7&++#G+wVb6ggN#_b!`O&ZBj8~{ap`*C04^@;&3G; zC5|^cnZeNY;&uOi@D6SBj-cc53)qSkUAQq;xC*Qp=stmnkaOjHVNo_p_>BPqh8TQu z6=X81Kp8=$2o*$CI~vz{_E42y4sfodY-KkpbEvkQ0U9W=6iidl0!1oc|OS65qh5KdW?ur{Z|WU|FP2U>+f3$-T&ugnRBnf zo*Uyd0Q$}MQ5fX@v~8(3+XE0Po6$|@;TmIT&~=*sECOF40o}m(1KEH`A+3`Rd{S0Y zaD^N30FPB$NfpN=7WxJ);doP~y8|O5x`Y9tkQ8ou!9FomKc$0h9~-5 z?`Ca)a3GHQ=GPmD!vOM>4wo5ShHq&+VVf|-w0ls2)RG_#n*R;$AK`+!)uC;GdpTgM8mgto*Q1Aixx$v;iZTO11Nu zU^OIa9ie&&W8;lJ)U-{$V}Eg#cdNI>3CqizB*OjfwwW%6N35$Z(-HOWH8irW8<*gi z|L-Jm8g*aeo%PnLSEG1_qtKQ0PrNf#q@ktwK{h;5L19Kg7d>g%$>}&FXHsb#NR7;u z?u=F{pdFS|#8Wl}UH@eVJ)FU#c4VO>eGp_?v>< ztv9qEP7SI(=Z3rj;bY^}Sc!yf?gtnJ>W>4|wC^Pa0~r3~>q{mhRgU$++*8?fY0>W> zf2U8KwbU_p+NFec9r@?n8xioup|ren8zV8iwI&9fwQOe}u2MAnnE6+lY0bA>OaD7T zK#YLHXG?=-7F09o+JQ(5NH6qUKdbCsLOX&;2SFJ1vp=BQe?nS;P*wXXr)DnKiv2uZ zQV(9a^AL)J7#u97AGDlbmXVc%REwKdQ=5Y1O@_H`Q`aPc>vAFgNq!Vesf0yjkA+NZ z0!?G?2=$u=k>Q&h?Dy|)H2gwHjj4jYK0v^y8>w+X5MgS}{c35w);IE*Bv*2PB&{~S zAP9t;)J_iH;`7ZXQ?bvr_gVGrmJ9|uI6?lTV*X%!p=HNiG6LCB5P6gKA*Q6ecH+1jIE#lj#EzgMlFf%6Gx>md~RQK(koH!{|{9p8?aXkz4_Yp@Qmi$#aj@X(bm)J|E_k2xR-+C|9Vwbm@ zF5=l`a9A0+iZEH^847n)p_8OZW@lna!TY0)QlcmKu@S{%uvg6_Qx%4=(^VEXVD!FC z^u>q1wkCLevez&^-Puu=X8UC?34Oe2e+Pi>Pa`7F8Ndx%=bFz;|NQ8G@gLX+`Y01| z%I^Y0{#aU_cv{g3Hd}cP>=O_;tT6nyS1M3iXrXzmUovsD;en7q<_j56#7#BDY>2E3 zjQ%7%w~nNl(v}!C{qi20h+BRUB@HbOJ3R+l0l^=eQ4WdfW`D-xVB(uy^5Z_M(!+Au zxAN1KN+L!>zuzV2R?FvMMaX1C!G{7Xi*BvP7lkLOcfCt2jdx)iNjJwcj0=i1E3o9w z=%}kxX+6tZ>f*$t*)+0!y%ixMO6(!BQ6Uph7}ZPFAJ?xqjdW}NegHf&Xk=VwLD;wV zz1?{0=^|b6&cC1M)*d>etVf9g10*unHOPYNcJ#jm~%6vZSjoU!KpZ+hLyBZYC{og;3vBo(;A$ zd2XhBAIJUh8=5?tU7g!Ux8=xDVTyB4heUc&#A2$~tTF5A}@9Jysk0Tm4mOV4l#`f!MNb6k8# zQ)`V}q`te0W&UXVFQvkLA+DPsX48OxNrmf~UmIT*eT}o#rv>}gs=R=?Yz|~gQ}Brm z{;E1I{+RkLw5s-;5%{4@i|}kp>h%k?;xT~$?lSs&Ry)AT-ik%IMdhU>kX13RdeoOTN$fvJ= z4(HoUn@3!|d$8RnSrPak9sQ5sWCDfAnCZXkr`2^N$C!eBpWw}FtyNVYo2N&GOe{*@1K0My7^gJ#0XE=@)eEEz9eOjV+Xbe zsrq>IpOk5(K8o0c07H#2Bj0X*@{}A3q73xzL+sKJHgBp{CL~dy%Z3*KHop$c~#0s?`9nOfpQ3+gnhOPQKn1c|c?s zyT5WNf=$oeEY}yD^d`nvtDcRHND^y2J$!*h2J^&tUE3*(M{NPWr4I+SkkAeERl1HI zG&t#XI-C1EyDFgCc)tj)1@#PA=n4PvSi7?up+3RY-25Rb`cQGY*oe(cW<_IFlBbR1 zwG3k2BlMRpEN~0=82}64tXyiq3~aS- zfQwq9ucS8H4MkuqZ4bgNK{rv#?v-3+ovbQmVDQt|KdGg z9txjD4ewIQD?@nZ%+8yhr{|uu=?k!DW5)^J?lGhC43WuQ7^zS-6+`?iuRtp56R-$7 zG0(>sL@READ;qLZohJjK-+>|53BDCt6w_FNqR!WP$afa+VF514!V$~BoR9<)7@3&s z%cDzi(cIAj4#%oEIn=7DBy?sbvT6{l{GHj#h!h`UjjBq3hnNV5OsoAH+fO??ywE)< zf%Dxky_RK(r7MZ@C!vwGSDscW_1$5C?xuFYdrX6;Ez@>8f=K;(*FQJy4>kccy0|$4 z6wO7qM{Ak3+FuXCiad6JMktv+=h+|n(`7f0kMGX>Z?C!v*33Wc1RX7Mm)bRLG?_DJ z3#2}YfBPxd@#36QQgvbGFO7$cH_{&%f=9m5iKhO;64S8vF(dPL={162x)qdy27c0)aH`1IOYj6L9zP=RYwd=d) zeMzhQ;yhVb?|YZF)7tv%vc7Y=4Dq!sW0c{U5YkIe!<21^t^SsX>QIPg4X$e02YMLN6goP*xcZwXJ4_oOAO zEq2)UWi`>?iCL&ZLlwLDA<_6FvDiW|MNBv*j(Y2z_m~QMM5124|6E?-7JOhrFS5KD z7rE&Ipa?9Jc2$|BEEn(TA6^xt^bzB&AjS5F<5QpexGaKfQ3ZY-{*Cywv~N-I_ZZYg5W#ycGtdKH=deX%8sy|jRkU=#7G9X}f1A1;-+t`p#^U5__P0oZG}p!_ zkF`|_z>cG;uESye8d8YEE)J#2sZ>>@CZjc;iyOw(6OoSqr34kf*|X>Wdp#Kky=+FI zl1oA$#U<6Yzu_A+gKSQEvKB3Tsid?O3U+wUW-j`yz+pb>04EMHM0^}XpwhTG;9D9xbNYi_ge(Z9CIr3KJz@GM+srE`dv)05(#1YNCv#w(3)2c z(%fmgYCZLA*4_t5F4+hDUstq?qn;|~yDtyZ=6%(%l5~XA?_9_TsFvNveaJJt!Z5wt zVbL9SMTD;hiVypQL;@}*#=RX6I`b4!)`7493)^6FpAhX(ra%3dhScDBu13eZ>dcwn z`9O{&+~5@MBj0HM>>0f**6E(hfy(GGUNg#;0PB0?qi*Qt<8%*J9grz^u29qK%TrV5Um zNyN<71`%DkAsb=u6@=FoOE4}Z6KBIKL7A2!b~uw^!c15r*7!P2A)wN|tx z`0G`xHyylhAEviDt_V0IFtOfwzBG@v4?;F~cFmJAgIvOA@qhdecqjTQBSFurDLw8e###x0Hob~&Fg5~&E!!Rq zqF=FWCzt4e6$H{SQlhmsO8jiXZ1LCe?*=6I(3B71DFT}uMxJZ*TlWftlTOwzEdIXv zAV)xYa`(7{GU^^v{FOu4p@>ZNu>fX?O16D()?uQ!0~G{q_M4-nX9KI(5v3Qbz(K%M z`)=)L@eeq$jc9sXnv@9;Ha@%!El-M*!kEVsYCzU#_Vev-f4HOZ0JVG~g@S>W*35x3 z+u`Z+_>B(pXG@8Rr%2Y-+AAuN8)-bc(a}*GZFbwno6HB^UT-H-Ua>D1z>u-Kl{^(; zCTpR86chlTVx!DgJn=ga9FYOLhXfkX?(uo#yJABXajn#}RZX^oI6$(PV{odZaiMb5 z-_*DJXUmbS_>P0jVYCjaE*6-M5Ppz9mV4n)Ih&B$HleOyiE1oHkg+qY;K!ID(5HSh zp&H!kL4l>@1w|?1aDBQ^i9eBVNqal;2`U&YVWVU1^txMV(;)@DUg4JzK1l1y3duzwb@!b=q`j}Y`m}{8W?k^4g-BFypz-IJxTk=@ z{Sl|4cUzVg<|vC^%aMo3Ev8sYNFemNj>=}G*&ZpH+p-Idipx@23_=3Ox<2tDur#`d#Et zUPm{vjwLOnZ_BOC?2(ahTXP6Wi0_rtXJq6*e*8`#Ufmwo4xp-@Qwnf&56@)e9S!`^ zXd5XwC6!fbB>BrW>GNaKg4=QgB!0GH#~w3vNEhFn?L1VX>-Jk>4(Ctuq*j&xFlOWw zNSD~b-lKUjFnsPGSXw0C#U{cddWb5QiG#7%kflP(wg4g634tLaRwTq99h*KQ6`h`v z82byZ2zd@d_uB6MveXCS8t0_^y0n}4?bV*Nr=<0o53wPkI5Xyv*g@^$_$-J6p&!zmO)@oII~D|cd0&U zd7LyFq6inY`C--H9^RIch$@<5o0C!sf zp!oO&Sjq54L*rL_RA6^?b#?psDEylC*=z~-aMn}*^qjuik9~`+KE>0^{u6{IN*=q< z`mPJ#+i!o3@3viRlD<4@wa_~kdLcz9bKdo}@Tzzn&9mdfea`vXLh&xk=LA*{fzAT2 z&h5WF?ui3uJ^!3nVR5JfC!B-Aw(~%Bw?A`ZB-8)21%wDQAv{skq4q+Y8+Gg*rapflf?Ny_S5SxH2Vf+Y>EO8z zodBa2+e=1|H+u`YIAPvEviar-KHH3~&ue_VNnpTnlF8*jD6L#{Y^ruZK3+mp02CX~S#;&-a+stMQx>T;aJ~R9-Gs zc5hZxQgR;xJ>ioid@l%0dA4hQnhg(kuHiT80Fyr7KGd&V6YN1y+(Z}lwaiiP*?P7; zCN%q;mj<&mTQlH`5Q85nzZD$4;W^bkM6$O3kAPBlqXOP z3mVO-Z+-M#4i|i!IryJ~#jXIft7oF`WxB7q?=OoN+?B2F8VrRDuYP@%O&OGC3Xrb0 zYG{!YMXIFkXqNI?t&@d`z1!JDP7#saG#!9`BY-eh0co<-=U!*FS&z*Y=Q%zd$$B6&3@KxixUYSLvcgcb#-KJ@>%O!JY++wSh zW=jzPIj70XaK~c7nkL{c@@aDvf+T}xjQ5=O;k++LA)T*rn;EkCU#9Pgh2K#iic;l9 z9=z~_EzV+7i()fmSL1yWbdF3236u&7TkKfXZ8r$T4qQBG;*2k=R-?YAnYrtM$y$aP z>ORpbqh=0)!2d@Y2_2G^#rx!Z?)mX(X);O{4IU~Tij7~QOjIT~ie#a}igLYB5<*F% zGoEZc&mYjnkxY}M#8U#sykx_OXUXD{6M_c0>`%Nnbpc{j z#NX|w!6JU!Z1({&EijV_0lSk3RZw^8pP968dp59h|1=4QUL~M|{JFs1ShsTf1}c~; zQ&s4&Ds<7Vp4;+j0>(}DlhC&8c*de=@m)V89ZKsG4Q5ULp0+VwxFdIF=)z#%&^Ve5 zDCP$cS>r;~&X(}2bhs%HKQuH{QX)qO7ostq%u`EX+q5B|{+SA@UIojFT+rQ~(ZMR9 z{uS{N+Y5dJ>LtoYLT~SsxM-yEjCnZ&V?%oA+;g8s_7GAiWnKVFrc>>#Hrvg4RqXzV zuHO4zaS_EwB*{$~ds-BNMR!Ys6hT^pCY}w=iw3FlFWhO|i*}(dh(0YFX8LZ!gP}Jc z{}=CGb)F&#in35I=}{Y`QYdKHRTU?2iEi$Y?X+VAZ-Wo=ICqCGQ?p)q73MVlt6 zWzn~pxuAoCu0O_W{z5p2vOMqc&MDh2x?@y?!YDREPPqe!AjWSXaB{Xo@DL@tj79v6 zyP%qPr7Mgg-7|l_@3|CZ7=Y{P!dpY`xJX?c z(WFqLezhqnHX;&~7L{`Hj%>;4CqG__zu;sbgF3wLx79K8eYUrgTu&Dlj?%Dci8k;~ zKRs3#B_t#iA&l25z9e>BrEnT^6_q?1`7qR#3S*s(z4}5c>r=R5kv6J>hX6I}MPrnnIQQ7DqGjIRz%^ zS^ir+1jTGDt_CD3ajNHgHrqXgp(`b{fFOLr<4f0WM78@^lnddc~?YGN+A=kG)oLsx@+If0R&Qg0IIclEh)!Hw01?u9Sj4ai@ z^!RE#d}yz>?A+M;l7?pY#bk2rx$f%pAq<_Y?^N0%a33@cPUS-^)%{_eR>8;(A@Ma_EL zg$!X(`Bgn!cCsVir(T4k2YkszG6i4H3^r`jC;_e?AH&EJis6_GBpR^_lA~jCtL<@c zar<7*9+KJ|-GEF)?gQ7y^=C?c2f(d^_Dje0-aYJO6q&bf`%X?yqyQPa@qT)yk7IeI zX_#H`h^K|f@57sOMjKWY_C>PWD{*?gQ|+og=)mcib}ara`E|e!yp}h>c)yeeInEq7 z$mHp0%0jiUaANbX#z!ibyTpe3WoK1Y62h3fqbCnJ9B52pha6Q*H={`x66;Mj67Rm% zu?VYp@@-1L6RFzWE9aeSKfeyD$F(u2aGtKpt;&rp+5F{^_;b15vo~}5&~c80S*XSP zrO5_1t)6(LZENp3!6T`?*`~AU>2UVMpwW2k-k%n&Y*4>-d#_;hfp-V@XvNWJE9mL4 zo_Muodwpl+i^sPi(s|F87We)b=3&?NX0!g>$FSTG zCh<2bDb9`mY5`JV(AezJ^j$MZ4R6x8Uj>d=w)mwFOmfmV)Vp_42%Pecb^iKn^Gs)$2+N=izoNP%I4k+9qx&12wWC^l_3mbB6h zjnDGi<19je5w4v#efWp0{jYKvJi=WT%Kp)_0)`qh24!l5u@rd;n5| zu(9U6^~1+&v-n2j5GZ2ZoLUPpLt6wvE`9r3fZ=n)2V^9tU7dNLU1W?B4#R;~4nbL3 zgT_4etVN817-8c9VTI8U6P&waH1&cX8Vl8_+S;cl=sJ6~p?PyKzx0uJ9i|Tr|6xCr zcbRQtC#}%)`aZP!-uWbMy6xc?c6l22ej*X=3RmSzk8sAlGLg{Jrf5>+3IAuaan-rV z=98xLxu{c4J)x4hf&IJ!Wg_;c1BW>!5_X%Zj!^Mt z^b~g6zM0M>MH1J$5Wu+Yn%LoPefTG#V6{j8iRpbrqxU9iR_41>pCoTtrD!wo*E6}D zK>h=TJdi}zmF95cNA0|%Uw@YtPyn#|!5L@$ogJiay{OKk=Ftfj@l6by&AFrJElnVv z^+~1`nS@eSnO57IYugvR*Mtm7II@+A3yRG*E?1h}2t4#?o-y9pKJE?m$i`t2efe@C zAF92F@cVSN6*~-)1_A%6OnxGbFX;5?tRH~iMw@3PN_YCf0PV;68Cl?Zyqv-}O=Q=^ zZ-DgiEV{25cEQ{YnF;qFiUHIpLQvQoOpuT4LA3oJT@j*5g)2^ts&?_T-E{v&D9Epk&;1c^9!j6|q4d2}T}5&2Op*Thdt{ z&`}vmzWE+E7W+YRuk3M5l+i(OKPQLrJd|VrP7lwX7+#=*PbpA~Uphgnw6NsTA!*d( zDMFEm=;gyBpiBQk0Y=Von*zKm43A*=3vG3E^Yi|A6uJ9LH|f)Q-I6dTz`1(p-`)LI z4_Twt(K$FboX5k(tz(MNEh#PCYSI97A5v)7p1n~)K|c{$sotmyYOP9}9F8V0su zgkP8xyW`DRMyOEkht2hfam$!#3xkyTv9+u(3^6b`PaBmqTXxq(&V1>TRNjct-2*^m z@^{z5Dwe7v0C_(yVKu0Y3s2vAurA}%DPGwndR?a!V$0`fYJ3=Lde&LSY9Ihxyv)uM z$}#`*CO~N_7P9OXbyHHIijypM!I%~w0iqmQIRamw7?Q`EyF_@yN!TatZ_wnGOcHvj{1_gN_{zw4`i>wZnD++MxT9B{_T zF*T}}38*>>(|bRLuCBrxo^WCwyc!$(ek|}phfm4@LPQXxpeleB*5ii3mxCEi8I3?B zJ;TKBSd1`Bp|a@8jR?sLjh~mLrRPTGovcBG+754_pwWlNQW*)0%gA@7w^^dGpy7w4 zF)^7#tU)X=FQ^$*kX2SzLT#!L9P~9aTcWC$MWbmvqJoW`id)LxpZaa&D5dlDvzlCN zT0F6W$xc3qjhp@rR;f4>Lfmq;I9iDey=n(yK8!jTqWMe~jjFI?9WHhyzYh4HzMH{h zfW^aOAn-A{#7ro_sZ{vMwIQiF(%fq7y_x?ZXZu6~KD=gW`zxH=yK(PWzCH7e-Tvn* zK8o$Nr{}0m;SflXOJ`FiR{+&pn3?s-W6nsE;pS+YzBUrDdS6fbJ-(ATocp1VYGx*t zaoYMq)Ym`o+L(CLcDZ&Yz4x@;=yY=1Wf8G5aI4&eNvm{(_I>ZX&7&=>NQ8#taC*)d z&{|PO51!8_*I4z=(3-4p{q3|#Cil$OIFntTT=1FSv9D*noGKew-akA5$}EPY1;pWO z8Gmo@Q+M7)7716{AHTcP9{3=}z|O2xF1~TOTUoD71zm5!90FD3-FE+Bq_7DDh(7y| zZl|k(x=_5(811AgpB+v(F&-Wxs)GznDq`1aQ#&~~F2Ca3Mzb2Z!eCgd<(Xd;pkZS3 zD-Z|Q*}rEfps|-p*Dv6!D8gtU4uKj*>37MspSOgQnrg*#z!`~0*eq&Q%`p#6D=^n5 z8&<2=rv9f~o9K%i=X-VaDWsKd0~!9jm?A!t2sx!cqEH2+LK@*k-Z1gSV5lLZ>Hd3? zBDZa{UCT3#nY{IK1^wFxQbAh>5xw<2%Lg(79+KswSxbHlF<~J_u|e zDcFCF7mB7nLg67?#z~=Q%=P(a%}FQDF>7kKD&c*!dte2?BwJ>5L3zRQ6vvCn6 zskY7H{sM>B1}YX$IHfHVOkyEe*VE6Bf|m$;G;kkZxxZ56S|`0rVvV^%Bz7&A%E*uNW_m^}H@}w731*oUmfNIgm*>+GYW6!e`zI zf#iKq{IbcVys{(B+RLWo;Ba{vm5Z#Bf!_!}&dHMX6-eT+_9>~TZ=abbkD^d}NOTO4 zalCFGL~K`AYJiczkx(7;<@$=xjJ`dL- z;{Hx;V#nOd0%3kDq*$Zfjya_lnL=RAP3^j&XybFw|1Dj-7o@76N=bkk=O2_3t$-^> z8P7u2MUS*k0v-KRuv!e2z6(qwia0nU%LdZO-|R2~&$g_8dKEoe;Gq@VQF;85QM70$ z2dT_A^NrN{T}gmKPt^qu8Ja>5QyTFF8JQy&z7$2v1UFq~ct#%WLW%vy%e-?k*O2b* zT2OF)SurIGbeqRql+WEd<%?JU&F7ZSMla`HjIOM(*Gb!NOWwJcmCMASqa1RY^xc+1 z6beJQYZz##!h(jPY`#f6kUt@WqKWktoZ({W3Qampu|ht2Z9`+OeRKb< zzI0Uwa%5YuCd!cjLs>SSchY!$`i4n^ys&4CG!`VGM<9BrPWHA)DnRgL);eE9Lqk51 zNqnFsP$c=et>-R1v#XqJkR<3)9M}*P}3uGJgUDvDc!qQ-MI$(<_R=LxQe)N6{sw_;b8M zEbkKsY)_MnwUny)$6#uW8PPBm%@LK2{LT@zUk)-K(sDTkp~}$piP6MA!CLe&{Bj|m z|K%!;C75e9D6oI<3C25e7<^cDD8OD}^{C(Pu+dv4-HlI&JUL6B~c?jA}~It6Kv z?vn2AM!KXM>G&@1=Y8V)!EwM0Ke&eT+_Bc$Yd`c&_uS_dLpQh!XOH_h)*WT|xKV6~p6Oe$Vc(LZv^*)+3US^np$T|XojVLT) z4Q(VtVzkYKf~b&eG2kv6Y2}157Xf>Kv-@t7`u?ZEHc`J)vRS>VLwgjnge zNH#8Un8HRdeit8U6G{_etz7_)r#Pv+<{hG-IpPb3@Ixet1t{y#x<>Q66xB!RadYHp zPEfxSlgbY<=2oWK8vQS1XR$(kbU$67?WUB`6LboM~*Rf8jj}2CN zgLwnb-(%>@?hvnqp?-ZIr2F!3EI@K%RN}ZQJvjpj23m9;k<}kkfU!%B*qm`4S7ZZy zqYi^Pfh-Awidjjq6)N<83tE3Ll1liZP&v5OUKTRh4R)C zb;^s&bvLT?AGY-ge<%g(<;HKst*q~#Aczr1)5YPC!?E$H2)p*`uV7Y~6z=-19PgM) zwOxeVN8fvGOT^J`q!y)M%_64a4d{Ixok2o{do@We+xT}1cRx1yv=^N;x=Zv_dT!?! zL@(PMTnh5hbi{-XM7?q)`chDsAeNS#hm_dU>MB#4d9ai`vg`>;a`Ko`K^lCfXb;ph zd|0lg|Lsu>3Boua2oI0aPiGnE1n;=2KRk6L#PG;nuaZ5{Pf^(MB0*IHifM5X63ndw zPIF4rND*pSnvq`Rf0Pawi6XMdu6WTd1P2_DP&cCUbl1F2K=Q4k&s> z`SK`B^6;=_r1VTtw`w_YDO7LM`~~qUBM;AC*KYlTDM7VtxL-dLD)-!XVgZp6 zMVpv9QQY`rkBkI|2#yp*kEpktEW*0X^>mn&qF1zcni6`YR}LAmap?#KfJQ)9gJITf3{uO9e! ze>gjQ?(E;g`@K|Vy=)o0zaQd!&9Z;Fcm-EhzDz{-2tZ!f8~tEL+Yhd&;8CFCyOn%p znctPLmO@QdYGFflYyJuR=2c07VrYn;-}w}RsoeMaOGX{m4LmX)@iYMTZT)t%OUlcqir|32@!3QT zUxEt~68q4S`)JuQIoT!E>vAa#o~lpsaTpnr4N}6Q%K{rs3udLa{kUXN3woh)auLns zYT6@c>DyV~6ikB1-Ky_s84?M`LerJsmBv&b9WlfKKV zb?7A~|KHnWISvY}KO>`^{Ys0$t5_CN#TXSnI6A7}0`WI4o-%q`L-)H5eg6teu?#?* zHv63B`e|{FC45|OyTQyo1h`<0*6s0@DVzwUzeM8^r16>qkQ_{gIZDM0682hHL~LPw zO>Z812wc_Rs-ZzFgl$SYys+xVB7Cgo0SAcelepW?YPft}cz`@h()DmO%kT|Z6<)t1rs&A7ZUvS;UAQZ(A~GnF_^7B#+Q-g_9jkZpg%!`Cz?2(gzHKk=eM>Z z!&iZR9JVmNHgRFbD>5+-EQ-t+mO*u3gSRESeMrXH<1lsb=+|7I-i@S)Uyc!hy*rEs z@o_tka`7=`U|{`ezBD`{3At^;GiL;978cj%&N$+td}TFdfgbDssX>6ud(CF3%g+F< znUq8?KKc?9^Gqs7T>hdL;8Co}@(J!5iRQ*r@a0~wlImQRIxVRUTbKLck0};2U%Z*0cg=Qlu69!cqUx8IGzH6Wxb5xY&d<(D38HUzVZq0Km`pjv9z&YUf_s=Yhs|>fe=8+zMQph< zE8_mcEmn<-mCpNbb8(gez9NkB=B$Qn%U(KFrG{@7sX+~`{xX9mC?@C? ztCclX8bu?B%>#uzBP=0MEI0?WuRiGU93UC~>f^%^L9Ic{Um(o zME*nlrfV9yQ{7(pIepLnaUkW@<@`=GJK1{XT>-Swx=onGA*HM4rkdgNX?-QLCoZ3= zGt~Y(yTwgii@9=(&3pl`|HCwqF!!ICl&@Lf6_5M!+Z7iwhk-eFKcnAx_U+fn)T{_i zm;>mZpvTcmMg(b3rq18d;7o%EpP1G#cxqVn_=M?cH4ZPgW7IlDn;$Zy{=L1U8zUg8 z&u1VNki#6*)}UH=9JTW)$%60UH%*&lXlDb@>bWTjm*?pb=f6j9;^`TBpk#_x2hQ($ zoA!OSeSMw;Rp8d&@rb_yi5xE%TO9mNXaPbPod!hsL_&-3nb&>zSX%ETGj&y7%!}~6 zVhQh0q0r5lkHf@KU3ghV0!=h4iXC9Fd@YznYt3z#*7CFU)zj3|boEuF$0|mg z1MI~}E9nFL6~%4qvkSA!s%Ro#-;=h70FAfE zc6oJ48%@ORcw9s4EB-X?c8(F%XUUm+cQ-B$U1VgbYH&}%lR@~G7M~!XnVY=)N6FyYzcE|d)bO~etACge?7{Q zdA(cccn!IZ@qfWy+ROBQNz!`d^ndBDx0yd)s%^~9&TehZZEs(@^9t+P2WJTltnkyWJGU~9;A;`LAv3mm{m z@s_+6#XU=*9SgzW^E-^1nXtkaqI7s{O%72#Mx&{K3uUQ}aftHk?)Uc{ zO(5EjYv2|$+48BLXnGDO#EqmPs>gArcSTx9FZKeqks`li zeBY}V6;$MKGyaDa_@{(Qmx%HYruMswLfcn9Qj1ifHb;z0;E_S2aLN5L3|~f5;AOJNZIUpIPV&=tHfbLd_LUo zN(w|D|MD|+SKW0qFv^QK>~a0s_sB!MRz+h!v(_!N>zcHSiu)*cbHcdVxI5U;Ci&A- z^B&d}_EGuxz0P+7ZFOy829`3$Meonbrx!wfe+JMJPX#u~Dz_r^LIjk9m557Of9bV8 zdNegP{npn1sBfibRj%co+kP#xgt_mkZyQWmK)Y`4qcy2zy+IKw9Pn(T2d|vvNlA`tTBLSi` zPCN?SDijCx0bwZn|B>eMFD#30ff;t&remy*tyc=+4 z$#Q{p7%7Ol1^s76pwZX1e<6TX?`E!r<$1W#?&*xq?;7TOzwz}v-ChWaNxK%~?r4Rs z<7Sl=tb1$ARLodmNiQ!a2RG<0ris~Ede!mDs>?K6&N`CwMhos~d9+ zqD9N(ILg(v`?=Hb+I%_EsfhhvsO|`3Bfcv|fynQEl%4am)sBZPcOuNe7GK3f&S?K) zXaJ`t`s^_-7T)D3ex}jAV{P@(WoRENpRRyW(2CtEnHrT%ltUarhVI0eI9*=P?%xW` zf6@vV+AZwtZ#n4yp6MeQ@L6TYfkX7DnNq2hl=()!)m&un=SIlJmn}J%cYDc`4T1)- z**kT_C@9U#@Szjnl%KYe+}GUhE#O^9Hr}B{+0JlO5({i2YmK(s9v&0XuK)TWpG6_` zWzB7A?=<6LmsR0)D@>otJUY$uDqmOF`N9_aQ7J>%x?d67E>q|^+KQ@h0^fH|5}Kv` z^FD5);?L(=Jb$;}gJ~wsF>Sl?y6=U4f0CE~~{{7RCA%Xejxhar}4 z?oA|;mAlQoZuyMO;70RWiYks3;SyAsEFLw1jToCX`i?k>Nl8b=gsRPsytjkN{zWb*j3*=W3^F?cB3_;7lQS!&rW&%Iv(_ zg}N8sbva{qoX*cUU3GS9Mkf_Cy&-WQGichmGDsx}_t*?0izxcSHrEKZh$?0T)r|>7 zN$Lqvwp`M>(^x=oHozQ8&2vZLrBxUJaT>oNGyRxBGcc?1*3&gMUN;(N>3WKQZDII4 zW08=~JS_HWP%a`|d9g=?o+rMKT6^NV(Xb^lVJDZ9Z}s+V7rz7D*OuxWO#rM~=Xj0E z;xbWI%fRwezk!Gx5EB^y!NzC;C!9xt*J?R;dyrXmlD=iY z<^RZURJ1`hEi^YTG&Rq!JIR5ob7-eZgdJyz8gqoxgwf%rNMK z+u}}_H8K_|6*39=$J_(rGSyTeN*NO^K1q3*o0uvd8@OXd)cqB_scQmpmJz$T4K^>-Mc%LeBs@W z1!|7Yf#3K#NFdz2Fc|P19_2*PMeQtn+dGzJrfaLm0@H z;a7HT&Gr8kHH@;Ii>@bZpl5h}{sk6WgT3$so%tbJs^Oj$C|(Zkd+|X_^%2qCB*LR&@s9a3MR+fOsm?DQ@Jg4`$SLrf*k{+wv)G2i%kLL9$1^xY1%-$O&od&~; zX#lytZ38gAAJXVNUGRZ;2Ryq-!-l*4YAUHp-}3Q;Y!NlY38=H}t9of&!twLvGo~1+ z-{D!sIA?*K!H!wvDwW!qcn^e(+KFeLsDE7PqRG~4%$A3fO0~+hlhV_54S-B0{%F+m zh1}f#9#`v=>zt;Rf$N_nK>5mU(vJ(QD^KS^9Zuq;5zXS*%>`b%K3V3aqH3$Fa~X&} z0pv7#ZC-yW^jgqJ-f6@dsKjQu?+k8=hoWusdEK$q3`FB5rzFqLYlMhX7~GE@^YJ;* z3Klz{(?Z?Wg5Z(TQvVqZ7^j7v8h~HB##)m*f9dHJv3AmFiDDAjx)2r&lO<=hE+|zK z+?;}mOe7!9Ov;4Kr!L4w!-$p;i;LZp9MposeTIsOx4_Sn4F58s^uoU zduCjHhoL}}H0@&J1X*P#l!~JB)>jWb1ncqEF*?A-hJ-@^bWnUy@zRfJR8QNWx>#$wu|1)&dCotgyD-NRgg!` zuQCSkF8W00cx(kkYFOe0ydW||-Z@1jx1easP(G1KD6|$!RIi%!#A6tW1isW>u*ACh zibh@94oo0%aQqhS4?bC0grVA_c%p2PPQ<3;Tz69vClWVesOxx#TMiFue|#1C-uO-F?woauRa?GaA}3F3{1$F{B!i z&G4hoU^$WiRacTDM#2|ic5IP%UASSISlBfX%bUNSyAl<2^+M7fL+9;~5#E0KjQz^S z1tc`p8^KIYKkFie9VKH8+}AI5SNZFli7Nr&UmeH@H18zd8hwAu@oV!QD9z!^0fp!N$1Zcl+!(XvnP& z_~f2-G@b`QReIa^q|}691m%zY3V@-*PH7lC%D%AWarT9{H+oM$ z+J*{OwXCYUIXx4D&S&NSi2VLO%eWDsq?p{widHg7Z(#Uazy_L$9PHSH%W@b|0II4) zYe7S5$`FvihBziJBNu*pKsbm)b9cv3*2;+qR-(5zzK$9=A@>$h`Pc~!A{<>x5>B*5 z$1;%^x!aK6HQP>Nyj6!~J@mmO(~by+@RHLwY4b%NyRmf4!?7D2C^pA_sXv9|hsPRH z_AmXH!`_2|_85UeuvhwhG9<%+NE6Q1pNu6OE@c`p&%`#;lHdIU3>{)xht6`*kiH0i zZMobm(j3oY#1rO*M8-}M$86zqWu@`sOk?T#ro^E0vs+3ikll54#Uk1uxz2PT+U(qx{HDQY2n7+tM zA=XbH_1oPi*1i()d|s<7E~;YV=CtvcwdOO0%-**Vm5J{Mb6BW+ZOf<2czblBq!?1CAHyBAmfDLq@?9aqdQVRYy=tOv@UME`dQ* z_4v%3I4EfGB%zaua4XbDRocXf@)?a=E*^E zxfWt#Xy)|)6{zyT6IkBR7OYRLd8cmmmp6C)Liw#%lUB6VPUhUZt=QgZiTR}!=fIK?_Ua2hK2^-OdxVhe=!WCAy z94|xf2=J#DJt*V5%9vFRkeX>~l@--b9DN-!1O23)St9Q_>GdOPKbL`x0C5Krogon_` zT10EBUvB+U&LuE-_3zERlR+8$4bCJ2H6SK-b9SkdW*e9ZQ!9v^MYqx2q=WJc3c;dU zqzE3g`y}D}N6SXkC~%dEA?3*M%4s6mdgk!t-2946(bx__VBU>b?C_21?aiONRT^Y- z_ArwzHWNE_)F2@*qluWi<605SN5UwJkZ$LIG!Q_6pkijryJ=->-LCa`e+9({4P~C0 zuso=6AV06q(H9sFi1RRWW6LPpq-Q204jQb+c;3|l>7Z8vif4M3irUz9C#zyf`gEsv? zn2&=UJH)-V7kqy_{2wfU!Ul57Wx~5eTeF15fK9ZJ8tklK>t**56bbh&k+lBB59dA% z!&z#TFTdgdyFkiHGMDM*V{f8$&@6WZdp`|tI@wUL5hr=Nju}XUr(PP>kaYP8lvu=Y z8rmA5tuxIs|3*5O$i-QeyiZk0&VX4aM7VF!<2&wl|J284c1$#SUfO+#X+d)L-lzR( zET!qUm=BbcdFmti^cSQB{T0`ByW1wm33nv!N_2}!Y>Iu zIB1~}z%tav_wEbhB+_VPNKH!X!fgXFM3IBfkN{|mT4|6zL$??FCZo(J3#uuY+7+fv znf%;b%8qQq^%v-t+v6qw^Nnhg0c#r@%%G7$nX}1i@gL*go6X%%mP#!;{Ct7*&*w3j z?f3jJG;zzx=`+E|#I)GvJn)U%CYjZs-Fog&F`aksbX8YP?H7i%2p(FqA@#HkeUgKP z(H&?A{2uvl`eWiW)Q^->+Z$E#efI}0K5$5+6O}#-)M|V&3;KqdTpivO6)I&dM?{XV zz=5{t=CBmftB#PZJ(i)wFB0l@5+n;28C{G4E7b4PD}33ie+s+o2?RpB$fFxcbNvKZ zEwDIP+O+J$u>yY0{^(GbhNzmHTuo=doYvbllI8^ntl@y{T(SCxl+*8CE>`QfUv>Y# z!nF#8%Vgsd@-i<`=GfoXXOA3OJL2FE2{vHhNbaUUN|o$ORZzutl_ae_6E}@W(@SX# zkIflZ9J)Gw_vT00N8D=1g#Jkp9QtWSt7%W|mS!nt1UeGkY`fZMNc<%KZw0U0j*stF#ySHEe2wC^>T2j{6zS;w3zdru7 z@2dzb`8D~IpGu$veH)p9LkpDKLj(6dfeLC+03Cz zk>B{Ow91uw-VxR55OQ-QK*C^iImBtwmBvBc+1W*i4pYjftSdZ>U!6x08Ky#~1i6b5 zprfF=74rmo2YC0L)X9-!5v{wi&8g~h*^Fo7(ES{eb+)1?Q4@!k?l`xjf~MY}#YF`J zoVMs!S<1V2DT~=WT)1bdE3@}vW8afKhX$N}22S-mfI*+;EQP(ow^C5>7CHxn$VK$5 zptx07k&G+KONBi4JEOk~Tq0j4p4}cUd0!VzZTH6P$ZUW18 z8BRK8G&eW*NJm$E`gu6Fp#QiBgz3wbpG5POtZ(1S$;zKBcf9tc=sT_cc>gDD+5WK` zuft>DyZegg!gs(S!sO<73BW;?e^hk5#LKfBEL8gIr1O}W(Yc*1RhtgwdcJx-m-DW# zRhw9NN+wnMz$2p%P;?8)G$r8PdkMbHbgl5gbA|_hITnbT=ND|*RXHKaxM0DvQ>-KGo?)3m zkq~NMW_zlq2&I{wy(=|K?1U=GG z#P5x|v%7-cxt<0WwXT>pEjHb{EEIiyiFGC4cxX4!*LS=djAKsc1>)%EuI`Yly$ORF z(;)ympp27ob87@on6&#iI?U{3jrzutQQZ zzWDKN6zo3^i`>g?*1Af(azDBi+qN_w!TD^eyF+7#Dp?g3!EW?Mk3>^-c*S;@OemmX zga=b@s@U58`K4DH&>bIWNDit~oUlxfTVkoT{c%GQa6NmyV~AAcbW3Qsw<&~6qY1g( znKm zxp-qy9LUq4WR14IdwK^1g@i^%WZ0y}l(OTO49YSR$=){Vtax$hhZ>tU= zb_q?x!UFnbZ7L^YFm?^GyGG4`M66dRV%q*k9_6?MJtck|o-7q=AT~9UAP#3P zjxaTG>Jz@978-V~u^K)K_H|*Hj&?i?rf7U30&z8G4o}S+i$L)6EcwsbDP7SDijjiO z90zVyIED|OK0+77&S?#Oaxs3h7lv0+Rx-@Ww9@GR8rKs_mAUX!KIH;?AO@eCvevdR z4TaN+t)?vDi2S$!BaN}NXtBX$AP^QA5NP?gpLbu4q_7CNoEx%X94|D!ULSDUx17|v zuQ~21*4j1S{4jY$JNeZxCNnWOIygE!d^lgeoGltetDMPUGha60f9l-0 z<}_lKqPLbOjqc)dy+73#`KImSq19HmF(o z^TZda4Hs1g*wRePxUsAeOI%u$Q9Ec%IX~$Ost8()eDQ?2U{vF-N*;Uk5&$s#0 zW3=W?sNO$_@X*N!@=@rLPpP{Q|Eevi*!0gbxZk>5-CLnPfvly2lkVQ!ubplgm2BAZ zmt=<|w9O1X(zNC$udqD$1#~Xke*#iP+l?QJnkw&#LiT&n5ui`xDi7B&wmeAYBaG4| zkG|offo3NaEfqmqVc1Y(`BY0pMM3l@h!MkPJD;T5BO8hM8nu`x;zl%BZGZyL(1(p` zzxVGlF2XTZ}Hm zq9Dk_h4Pai|K-tAX65(-6FEhH zTCGNsnc;8GDoe_&6XNHY1pq8yO|RKaQAtTEAtRPp`q#NHun190TcgfoAey^W3y9P{ zeZxsa^Y(mRJ4I)YdMz#_{7$nw4C=u7;lkW}-Ryb1Jz6d{*=tC%RaaL}h>s8KG9<@v z(Bs8NDa_9|H8piPT`5-1S_2T_?`%fhi45xfKmlK>WzPp#*M45F_PzS82sD`GWo4bE zaPDu+u@dmV`xzU7sSMNTaqeh9FHq{V--Q0*A4Va9)L{gM30PdKbJ=!50s7y)8*S5J zV=voe1|ub+csVzpksT7C?3~CI;!YkLz>k-=yGg&nu5k~sNTIDNfJ-Z%!*OS__%h_H z054Q1653B$mQE(r9Q$X#FFZr^9>x*@s949wY?&rd%AO*QRu|K#BM?o24;4%GUNh}q z2TG%%X}D={U6VSewmm~5(Dr`sv8*=}^Eo|LIpK<23d@zzY%U$%`)N<=|EM6TeRL-Q zQNsr2XTA5JMJ|#xo{b_*l?cu6?Nvk_RlEwX0K}#~B5-J=eAoT0jlah|ccYAE`p3u| zqUKls51R!hl+KL~yR&v+dBg=sMmj&5K@cM;L8VLt@afNFl3|)AtD$`iy(v|C?n&hA$y$yNJ9UAz zRy{is3nvkQ&A$jMbHAF_6ot*#&EnR6_4-ytx66;lbt1BlwLk(#TYkdgw3hqHgMdhyN2HZK! z3e0HelyGoyckytD`JJxMH>p6~Mfdmjl9C|_*>^l$ji0yVb>v_EBxbVsOMh#33L?r>xGJhUw-FVp^Gw;QHiU^%2Dis-9`RABVhbnQjnE68f3}O&0P+}0X#jf z-G`&fj;#nrICO&ejCijnkBfh`y>2__am~$Mv+=qLc7k;go?Jg(NVY+uC7!=hegL7Lq2SFhrX(V&c&DQ+Lj#1^XTpy3DWklR$ z-nFcp{7$aO8boiQZ<`rI71MCwg+#ga4(9x}G3$Au#oyoY;Oa5v3?A@KU@l>TYam;z z7IyC``EHZ>?UuUA@l^^}1Ju$Brj1h>jJ%8cf-u=;fo^EIo}(W62?<#EDe`X93MG~} zen18sg{l@*_n9RucZLo2dMf_1EoL54?z_oWWwUqMA$;GM#Q4E}rRhFulja#W06A1b zAR%T{q!-zLhv)OZoEQFjYiHf2ocAbq&ex&%IKYiIhnc0Bz8!HZ1_XQ@&(VI z25hsYShyMP&o5fBlLpehLx>RgSa9mHRV0056Ze%bxxx2^Ad_7J-5p7yo?K-qn(c(z zhHk0zSqPL&T$4<=TR4)zeY+YY;zu$Il5qRkz3>w#FIA1!_UxJRfOGkVbgk6uLR42)OVj%68jsP#xg8s=@NxEtiCSjm zWtcdFNI$h2XVt7Jh;;09aR#QLWbWkzHH(>_@i2x$l z#t{7N7Zqw&bJaANY>Y@enGnlbq`rqeJv0I44MJqjxAG)>M?~lrV!j%Eh!HGQ2Z0J( z!4jjO0c=ccno+!Fk^Vl*DUn8mB!$2)0(wy#R3^1G{LmY?mUi5pG*P_qKXyveus)&T zR>HwT$nx8bdsk3~SJHG2-|QrJ&WR?#7{KGoaB~F@kCa|sMmpV+thAfl7LMsIROmH2 z&6VtqB(r|3F`dfoi(Kog_BeO{ZysHBMsRH>FCjC0gx#1*$$zoz;mY0_&;_!o#wcQS@Yt^A$CX3bIm}Dmc9y?| znCU1aipkquHB}$*S^vOkU3vx*6HHQ5#P#irao398Kr)9~HXsu!_g3Fiv9Cst(QWT! z>~ygJ@ZAvQ-C*SN*hl#E@gJZpz&RiQTN73DkJ0ldwe3e&!ZoUqsJGlhZALzgUp zh3HeevC@S+VUu5qDBq#!`R`V=%D^c5eXs83n0yP}l%(?u(^L)T;_0EvU}i&e0ou>C zExTl`wCL9QuuWz0*0*(JWVx|&aDn&R&Mxz(#zpIq=H7^_Z(!Q)0ZA|pIB*xVS-~C# zOTUYZmwCn;EVjFwG!RYl-sNt;Ldd)E^HRIr>KAUCc>w6KUaG+z3D_Ocs?h%F1RI?s z!-enmya-Q9&=i#w}Mo$0tdYa5pZ zTJDQ<45?^RKoFc_+i{JqecPX8UHhMx!vs{)U-u_-eAzH0kUh%o+sPt=j}DKlZ2*wg zrrYSMY9@x4dE<)!M(nMgWPk`cBcsh?RZfb5-_l}rUW?1YCv|n_jn06R<%Ye}B`tM5 zaSvI;OKqF*M25E-g@3uJ2EQ-vN3i9(<#WIh1VQ`6?__Y~#1Yv%?c z6D)1jQCx!)GeQ$|Szy}!bWCyA?sjbbdfVN+S09+W@x0gPv323QSHH)v_OCY` z0Y41}_?YE(&w)Z^zE`)0F9A>S;@qfnpH-mv zDjqDb4=6`yjgOJiY{{2EfZ1LMEXL@fr5YbL|1rIR>(HT~+j(kY{zfae{rE?I_oJy3 z>5;3;;mhb`GT6&(+%YfF)KqyfPQzJK4-y6&rU-4?BYb1Bs*AXe z=4cdAQH5xdhGcYd@GTiK86TR&4PNN&n-J$nF=;t58t%AgU`+%$Z$1680@^RQBZ{85xU^NW_hj zSZashQmO@HJb5D2xUqsE?C_4bW^TJtfN)kv38wzj8#?5OAV-hG!d$x2^1AVWn*M>w zp#JmFG@v53TWKb7&$S30Gw{LPdn=Gr3x|@BO>pL~yGBPgzbly8mDotV5YcT)w zO;5m`OyEP*qUy5SiBt3K;3?qe-%^&pvt!5>8#hHuMYRgET0lj)ev8TX-fL$NkUzf} z&5E&nPG;7YNv%SH0cvaB=76=e^p`xD%~jaoU-33H)LdmEf1H${?7#F(zorCNT88cj6?-s;06xR(g!Od2ap# zJsm%D4l*=f*8<%;O6P4D!6g{zI2y0VwRI-9rK2{Su0jzrzjHd4pb$Yr#P`i(=Sf1e zfZq9PY$}LM10LOJ3M^?Jt{I*-p^@}PJArZ|U99$fsIp}DP`GA+OUt0^hj@G0LZ1LZAQiLCBHpfNc=KO`tYE_ zdtJR{zX?3JU(^~O~p!hUd7S9w)L`CwQ!qP^JO zcs7{3e3*DTGy(zNEi+j=hqV%3(#)&D3H_%zR{e)Rta~8z*R7CHGB3=#IdRsf!@Y_3 zPmz261>oI<4v&|G4xbm^4j<9&zV)X|;LCd4p3LvS9eo0p^#0GMjoS`SS(DQUTzkg{jaJ9 z*M*-r64##&cMYIBeC{_5#-9JM8a#}#?!CP6@Hn;iFIXk@+imRde0J~f+rI7ayP7f> z^WNFyCG+*{v8mVZiE^B&#CP*-nN@gp^t{x)@c}^u~%x1;2-$J<>}Mkc_{ z>O*@<2F|E;O44e*`~<0x7ghGJ7Oja)eqd6}Dr*klwj~)V3!7`Is>peH-A4+?fEH1= z&HUtV#e1^meQik!RhpxMa7)%~LXD#E1sPiD!92Lr))Ixjo=+OT^G}zn6){Srs8x8n zOzv+#Ma7e@pim&JRPV34FI4FJ-c4>g^p04s8%@S`VbdwM-^CXL5`>efJPRo)DRK-M zSw+!QK@5kBn;)W?dr8T1v;ITzG|0a@+B~=WONNIVY?sl9c%EuTe&~6xx@zgU?(WDY zV3;Y}C;YD3PR~k;zf1zUUlqJI`^74wzG2*v6xQ{2-{q-1X~y9ZqQfJ{yc}t~@0%D0 z@1k32eGJ$I@TGwFL($3R3*;?Wv4ov=CIg@!fkIl00$tIhznu~kF+Cye8%ntVWr@XR z9&JPGFDxfu4gf5MAhMu<1lLiSUIhg>zL8QaWhxD87~>&PIs{X*x{2Za*rc`gY6Y9% z9|ZbC5qz1%=J4oTCN%qm>>)GhLJ(2zyR`%cPw3qhQ|G(d@!SB$ooQAt!q8ovwa7}Q}21) zta)`G!p#hi6ICtDPQ#Ya3FpuLDXtCh5t?NQiciK`GMjPk0oiui2ajpCBh%F^=k;$nIhCYJZ_Sr`~t1XT@P@}Bx5FSaO|{`p0dG++t~ zfyqhoj);y$OHN*UeO|djG?4jLc*dK%A}1k@_d-LHT&(RPc4SeA3wO-&ElDdSRVyiV zK!r$iUENUE@S_?<6hQAS&1?TzoAfMr-o0rvUR>BB2SHs1J_tWa^ zbdc{Zx453m>?R+T+UzK4;TVVwHbWrW|MT;9^LC3|?N?ZD^=!Rrr|7+nGFEB37zucM zz_UBrSF2m1WqcR3eNCI6cD4t#{wdE(RLdhwV4QHvAk*UT_rcUEIpQn;;S&{*I3eqV zvuDiMGtEiCj3TOGi6xVg6G_K5koa33?L&NK1QBIvGiB|g%=7o-S$S^F_ba+}c6#r( zFWzf1HEA5r8-1p!GUh%l6IN_Ts@(l9fG~FR7FG zARV2?YO*d^^qoPg-A*Z&@68EIRq;iBSFj}^p~;_SO;Z+v!X6G%ZNB5H%hWA|K?gq=1#iQ?fUI4!HvOlmyVfKFnCSl%IFTDtF z;ti*5cA{`wT3Q+!8jgk(Pg*PUu{pkcb0~C$PytzaT`$jdZ62ubpuwnE5ow~3B*6=Sv9TeGeeQQROh-<)2h&pzFwsyjocl~1 z&fww;qG94Pvl6Cgal4t}yZhA;$(lB0Q4M}N zkt-o2U6h=G0~fQP;L02&PUJ(#&!pIm2gc1h>Fy%hwY%eq7Yu?KZSMYs=YDM}&8<1=BHeN?U zpsMg&jaflk2HK`ORtOK-&x8sypkV;U*31N+I)+$y^5An^Y&?TJer^9@zSx>lHMx6~ z$f4aEn`0J2zMZdH{x4q(Qe`8n-TnAU6zdH}V z1A(05_$RG@FFg(3_$iG}$ln}HtQ0!$YG${|izk%{dk)TKXZYiu0ZH)wFTcyy??1Oy zJ#Vf+$6spKe^=>y?rI6SL$xZ~4o9IY=QQ8iD5`#ZQ#x(6<#yWDG{Vup|IBX{uo1)5 z#rinHvdngQsdAEeKfrJG8+_90a&we!lqR_4T2~Z!^L5(sb@OUsm2JzlN#95Q_C|1O zCHm7Z{mXBkP9C$B{SO0$uD?|&b{&?QOt&^1+tMn*2Xucv6QSngNL2i90k(d`POj`^ z_kP5IVurU2-S7StFYtciYv*IErlduKmtlU;6|xu*EmS`AX)kkbQz{H>EZBBg+mT%d z>0fVj4L64{;?QB-RC+*^CLk@w~KJ{KgJ3mOzd49@$LoA=P0pyJ&C#poGCHZ3$I zx51rtcl~Quz^&~}%ATWiL#!Gt(#0J8TQ2Z>?@o-w(@5#}kdVCPWq-XUd&HZgRds!R z9JJ3PD(M(vL7$m4w*0{uDIzW(&RQ@l9PoRRP)?t#;*Gv=R3d?5 z-`ErTsiiQgjl8^GLEqcFg=2n?lkpWT4YzV2SEvV@5)#R3__f)1 z&~Avx3BgKR3vrxFi<38efp!)RBMu4)zlYuNVnf--@?`+SuW0YyR_@XF1-;GufM(ii zEREZKu|E3;}y)NZ#vKhP`#przh>XYtR7 zXqCDRKAlg6&jWxyM%ZiJSXLG}|5M=H5Oglpo1L9-Db| zMO8J531YR1r%&`NhXhzTsx|JS4~uL*VqveA>FP@@Bfy!(wyK!2bIY*hbr4|iU`I5Z z2ffX?Kn`t@sNNu!X_>6=RP#)*c*K+g%|FmMVQE2R zFnrV)_6XilrKSa81=K&o4`4W9Ow5AvPRyqn2-fN474k5=rpdQu>F9%^0$@SPm@o=` zmM1l7)P-FBB*_9c(TEzHeF}HxI(ks2_3n-2vN>kmTHtf;4#;#R&e*BmcL3$MnN)0+ z$9;WSRqI75la(Rg^(OMxAR3uaff3+>bZvD%j-IJ;EmAVz^E}B)oNjVFr4R=L@4C;y z%va0bnK6vhRcT*D0#1cGIM~?_4i92T1zSDFzkB{=(eAK(j;HdDST^G%2#TUnAm`Jz zrVlkhC)rBkkb>+*n)-p0)4XA{w}aDps(+*Ji-qTLLVQ*{4g{Cn|7x_?D=RDG2Nqam%BLrSzQ)bK-VGpd`5#s^?RIi{zVM$OEjL`=MQ&}# zq!Cu|(27bk^YS7zS%=>o11D7G-nuBS9Jqqy!jIWh1x=zlW4gg$$WRV8x_Xv%%>(a9 z5Xqc(cTm8_hNSyQE@Q8>gNR9Zf9m!<>G~(+LV&5;0fd=mue^>pL*e|}Zf>cBf{HL; zs`ftVZMd@-4=BKy(UD2jS13^aoTHd1`1rLsy~`A9ox(II0CukxlpaJZxBtP{C?5YD z76KZQa||44E(@CUAA}t7!Q^P0{vZ~ZPpfgw!}ad9;W!k<;22z1e&yW*w>6w%SI0k2 zD<30FnKqFc)U|o?CMFVkro6Dfrf23KALg_EsZ>OKRDX;W%c=N8$fHcyE!}wOw(T(w zJTl$VChL9B0j#K+1j=5l(OLQT(xx{thu{RzFdE0lWFH2^jYq~Pq&X)b3L&XCAts`x z)WS51mevD?cF;tpJ~9l>8Z?*yk`BVc`sy%3lIdifLsK$R?n-IKmZCtf**~>_rTU{iOmOgarkB&Ip&*~&Kbt%qZVo~N4Vfzn=_Ydbu zsB$3@f}x^L63ZAgX7)z69>>sW&JszW)dG!P94rzqVt2z62sD%+rxLx;7}t%(&Ox`c z6Dxr$LD*ott?tL%xtg^yy#!NL<9^>lnHesr;jMhwRf7dhrEX7y31@$g2pB;j&Rz)^ zNSyPY*m5?T3}RTAhwL)4Y^xY4T|BEb^}%9JM#S8Q&lPf8pLS#kRoGVZNt-{aR_w?Q z+|8lC{gF7j%~Tj1tNBouoaw)o7^@>>n`q`k5pE5d}b^W|GYJ_U)(GIoN9# z8;<8o%#@lO{2y;b=iRr5Ft-N3k$mugd|5@ZMB6cjb}v@#H*&Cbls%+7grmMfZmAe71ZSyEUC zg!38fLFVs&uz|1SxkNEGN<_S#wk*n%1QL=0&?nWp2JU45mi7L0%Wby<$TFr4Z*Qrv z>%y;lke_UQ>xXpUP0v^TKDQi2ug&US`DGYE8&+(QyQd^1T0Vv}mEZwj=1G~}AMyMa zbzfJu*9%5KcY(s=Pg}dAS+@`57^9Iaa29u~ifG#LsQU;OX1{+d2KGkr>?ulgY5WFN z;|mM6bPpdU+mA+^nfS-(neyqmJo~*~Q{%;1{OlU!Z`tgOR29E^j}b&WPEyR@yQEWS!Iq!7|W}0 zb&FAFHMz?7W8eSZEI=c1G}su6{{eHvZqXd?b9&^q%BGTxWw*+^|EvYwI_BgAPJ9J) zO;A=8^YCc%96@uRTJvdjKJx;%F&SBTmt>_2q!|==(waD!=r|%%{;!F(27xNzGdzpZ ze%wSZwJzt}{_c5+Ul$M2O+2o@-qH^1(PKseM257pmsn5fJqgt}Zg>XY!(%d}d9%tg*6PG}fC&Ojy3Q8S8H&blcf0$Hoy&R{U5KcTY& z(&wlcTt{GTbU0TLjz%U{&;FjK(6h=gU;0}+5NVW1t1NNVQTDXlqS&2W7SX8p`HBji zeOby{w@ygs=C2$H|QzP)7&iXq`Qa62CWjPc_| zl|Wp0Syju;L78K_^V}>{pTq6$>?%4M*!8&|Lc3lb+IO=9UkdSeDZ{z|M_NJx79@*g za9|t-6Dg-}hP&j5Z$qZJvlA9sd`cWUIQ~~|(b|InK}RqfVu?BWX!j#<%5=cL1Hska zvn}>Y6H1|rjr(ceXMUO7K4mBV=&><_alUd+l>X89S7{Z4kBFyyZFtmvm@X+e=&fC1 zW~8-$)$dSXeC;QuQ$5`X@!KTqu3V(c0?Ge-e$HM9jcJ3(om_~INNcugk5mydMJ9d= z`grVj-a4H;2xgS_agFpC#>EPLEEG_d6xg|E+!Uv?`z3zsH^qOw&B0{pLJU}E4F{(w_{!35%RFcD~PI;(@0UWW&AiazEP>lGV{wXTf&7d~?Z=T4g#Q#ApcrqYY zdvPy!T0dWQ%2I{SjZy{A8%DF+trioFx;CqfvR`I8II=n&|4!c;-8UgtJ}iZ530|(x zPWWwo=l_YGh?v%ErjIcjKg;X?F^n$9nm){fyU6k};1xn`$ar~DP?)yEBP;Im!+9TD z&i#G+F(wDE!txzh&Wj(X`$Q&w(qK|Y-n+`m5;43pT!!KS86*AHAzWVzAp{3fN}a0m zIeim3v|j|KBuY;iR9Fq88_$p3>Saq;5i#e2cjib^JY4|e9ZLH#0~VB{oA`zc#lJ-) zb@(7tRZp(vV{ye&5RA-5U6Od%S2P}lm}OlEvaf-GK|nyyYDWvuJ@WZ&I{{*-k4udp z@YP!mpKV~9i-=D8r_4%e5bbzcO&t!a-pbMxD~?(J;H^Uze55YebH zm{l*0bNyWt)iTjad=216rRelUj?XswfSy%irBFVlQm@P5vGF(@jg-&%iHGR+>H{)d^Qb{;&0ke*9Z7+A z6|5NC`Nr&4z?RdAnd2<-?c2e`KCklw5K)HKyDWyzsss>2Dq089-N>xbyJ34<*;y_OO1+nXB`tSds?s8L!J(gFAY?dFbl`64=B?Y=kLCW z2@CM@;YIg4Yj^Xh3b4h)?)NJ^t;)6qzyz=R4@Ot6EZ*)Paor|yNG$#D3HYr$xlnwEFIgg0{ z=|ee2VdOm(6#5~o9C)7@`O7Z1H?Tq5?TvTBRTTlQj_1K@Al%_=3=Z1FICAJ(fX(o`r6{0O>I$0 z)c{EoQWe8AEi_=ky-QX>k0CE<5@2B>YGD$fX(Fm=V&bkU>Yghl8jvhyAu1{<8sIHz z=q(x`2`liXG-~||8;%Ap$XinqarB}Q@5I||G^B}}*=eQQLY(C|VF7_jnXCtlYk;QC zxsTwQgl8I~d;AYX25heh!IYhhCAK_jYOXoWwy$iVBhQ13xA>Z*jFo)a`h){DBcX$g zX!R1R1HpM0QzLu=o78i`iV~A~h>eTUc$(&4RfMl1=9xSwy%kl5O26Cjg%d_}NpI+4 zvx6sLGos^Fv6X1i_t)d*aVbIwZ#0M0O_U{JjBxf~;AH7%<5*4zvsENuO(t#yK7X^c zn`=emXr$o`fmKrSP@B%=wo$TWM=-F2w}ZX1pvDY&LJ{x5M63;KQ&UR&nzl2S{8mGO z?6_M3)<%3WxlT}*9ivbNr8-f$xfVV#NHNNiA*-9VJ*gRk8U~-Ow~rwC0@oByu3*=D zXJ_Z}hGae?BO^Q8NKX&?Q!%5{-#3;B5NYnIBCT3k46o5iuSld->dD<%=Vv-w(^Jz2 z8&}&ejR|!W?&OLiQblE_$8S86@~r@EXQw}+a#9jECucXdC-JbCv+-HPhInuEy8@|@ z(#vspOCTwrjw}JzpNk?QJqY5K|O#6T^%KkUOSqxwHu+xRx&SqCjR0zt`DN6mA&+Gj>%LI`4O# z5Nz_A1O{>)v$LAVwTp^rJV2@d5csL*n8oAl2dwU&wTb8He4^O#=Swofhc4kU z|EHQ%((tFg;=OyN%=-cPr#^?v8)(g=axcE62Ce_W;x8v1P3 z@PdbgUg&Md@4KXfQ(vC;GKGa7uu5O|<9We>e%pn@xkf{49alwb5s>vLBp46v#O_i^eTaua4oWMzoVT&vLBDB!1pz3RwZ=4cNi;t^UEke_>?7f z%=hW`^5vp;D%DoJ0jkjcw>CG`oVrO>X8up#7;f4hefovoPM!mGyw+adH{YJ$-@dFp1+uJsrXvnQ zB%+2v2!dOe4R3-Wx1Ge6D+!=2Jed4>XCOB)k!bre9?;@Zv#D<^(jXLp{vUG>Vb9nC$ckhX0ku3T`dwtem#`G#2LNnEj#uGL0WWqG-AfQncKB`Y@c z%<&oTEWLg#^B}Dd)Ws^bC;z~ZmXMN^m7cUp%gZ5HVj!h9cr#4GPu(;MX_R#iWnLAR z#WKBtaS)=)jnG6>kPQM!EvLdk#+S3=*m5{~40dN^|5hwv?S_fRx2KNA8`s-L!{VwV z2`~`TnaKrv`=f4vND>H!dnnHQg>lfJGjp0BKc=Q=CZ(jLCjVPHX7Q+|WE;h`vD^;D zQvouUbi)1T#~V^!zj@&N``#R7CC>sgrD0Kh@kNo)AnS27!necS#&nc2mc7D1e}x6Z zCnjJFj_FofPW8>p1Mjv&5`k`an!2NQA;k2M;qvM9O z4GjJgp_+!QMk-35V^e*m%AKm60tl8DQ7*!p4S?}JF>$)gvr6d)Ka~?R?mAk8)H@VX zs?L^_A-L*ZNSw98U3fEZ{HbqzgqDH2fl3@u+5El+GHSG2OwSVrfbIQilc%HeIoom# z=7c;7*f}3iuwXx1S~MstrDUHokWsL>7&O^if=}vIsic*%1nvP#XBMZ;MYTyEuxKNQ zB=-vR0^~BlCf%sUZ{VTf(8FQP`K-HVYhdtGX{rB&r#}7`Iye+5jg7;>SO(P;G9$uC z{GYwdi4iPhN|=QoX+1}j7+|uAqp5(G5CJRTj1`X~Bd2T+x|-M*&bvh-XUYe~VJT<= z!IV^_IcG_|ZRm#+98(b8C@~?_0U_APn5I>D#AgGJ!Y;w0oIzY%cQZkHP6QfQiIGbL zx!g}Xk0guk;0NtHgT!u!r5iQ#>>#-AcHn!c!(n-Acr_8;Y374 z+ubFdow=lXg$>sYT<%;%vs~_)M6=fVDn=k3EiEl{>6~0rUw(WdZ3|} zxq{Y3T?V|{)-Ngs^vf7vqJzHc2GoXGfVbo>NhgPfN6b+1?hO|wd_?V%XRF&-eU-uO zYzE!Rp9Xs?sB;aACHGKu94?wjupzAvw%RC4|*kfK`}p5mY7o z{k~YQt;uEh<^s2kQR^5`q1Kr$#6(FhNmeds)oq9zfA?<~y`6cts?*{XzP@t2^VL(M zd{wW5sjIU&iV_E@fVQxZuE_={Kw|XID=4q7zB`)?2@Q`q5%z+=__NV2E*&HGcV|aaBMA}C z0hro}heM>aJA7|5$HpKMf`ziCgB$8}3Dg}?Z`nZaWTA=d#1Z1;8JS5*D_p)qaXrru zx2Ma6>bkl_Le{`&%cT71X$6Hb+ios6@~|` zr=)9RvB5%Q6|tl#<7;aZw&#($98#wOxtGW0#3rIE)YWYd4pF*%BakWwKjLp?=U82Cft!HXw$Ws}-2pHLn5d zw=TPdDX5|Mb#kgvx^Q66js4PBlvu7XF+hRKOT)#>JK+8YtBy1!g;<_lC1xQ~RwRLy zPK5J6jW}6IaF~|*^dcYhYIdL>2m_PaXs*o9hHZsWCIXQ;ij7Q+ST6||p`YbXJl|P) z3SRwK>iiIJhLiJY>Bz|*2?_9l9vo1iqV`iGu_7Ymagfq6mWn{h{bF2XUhqXyhiDKC zsBEEyQuqu)3d|hfV$rdn)B6qZQD)fFmui&H>L3REZ=G<>hu^E74@SFSe_a!DlrO~@ zlK9-AkYSqg-o==5{s(PIKgrc4H~P$$_k&;SbY^FJz#_uc$kb^p_Y z1Brik)*dgYi!t;6D_>Ys>hu7O!$HxtH$NQ6KsGr5JxKTbUH%K!q)S39#1}nh( z10_Y!mf06p|B@i()2PTqGgQa3Z9S$+F4H)ka_7zSjMQX%v7Z1b-!u z%A!wES%fIcIccr0C}s#q?lf#r(OkEoL+w_yIxGUm|AO2zZe|^RSFF@lSw7}ItbEQj zklgs;y>T2azJ&hSgBa~t0Bg~kr+rJM?{BF45*n0@5iR-Sqq_<2jRyTT^n;f>>IRTK zt*KdD%BS)9*xK$Mi!J3HxZJ$ma%ld`FBzvQQ8iEd#}8cm%8M3^9Q; z$%OcVYj`>uF85CFNbZ2z&lJ+~<3jY@P=taWa@*Zb3-0Jmnu zf|Ypzm0cvd`Pep(--SdNGlh0`9-(QX(_Bs%j?>E3)fKY=Uw&19|0A$Ei%bUqP|kN} z8}04wfEnMZxTojtdd6Hzfl_wx6uC~x`i&j62#WKXEvIvBI{G+_ydn}Tb|ns43=Ji1 zo)eG0s-dCFCJ(c|t*&m>s=v?m0Z(l-^2C>>Wn$iMw(3>bG`LTD1R2x6B z*B8_@HCtZ0UvQbU-f34m0e%)eZbv>tzjDAfnlBZT8Ra%iqvBM>I5Eoa<2L@i0TATq zHQ3JnRm`|KUJK_ht*rcgdma<{n+F48+3*S4rd7peCS}BaonSM~oDC=d3S<+3-e(5r z_poVbGMemk+RcZj3P(MU7eg~xF_0`9?V+9ij{p*-Sl-92lFHSD@-IQoe+rE5`@tZR ze|Kp$VgK81_EwQ5EQS}sI zZYs>7!&;T61&C^GmTpJsvmIKGq#~y`f{*p52n$S1S;q@S;uD=DKG(eTjSjRveqQR(8AGE zM5dZM-z1BblSSM`oXHg^qUZ_}>6tjplz1OfGt}r`-28#mi+!DXV!+vQ5ut@i?SoMT8asSLa1pN;r=PRUw;SkcPCdcgP z=x7ys4GnF#v+mq{=?lQZvi;kWpLO!6n+26ORJhYZZq@yopa!P}-G>z&++;p%s z<*tZw1@u zZ8}pzR_a5Eq1;So7zKz31@$kJf1K|2K=3^EGy7(mMtL6j=h$8%uK)~n;<5g7b0H|F zu1bL@Ux_wgPDgVaI=Z`O5<(yAJow#K^@_sUJW<`b^5|)kef*hHq`5IS_a_=scyG|I zca)zpVa7bC*)1~*>84TwL=;;%l3?+fJXt&`|WM@ zc$paMgOawh#K!R~a@||n58G`NJ7I!1em4R!UO15|^hY1UG{w`1a(tt2c2;I65Kl|u zf<7AeTN8wXabUN`Q^hbfHBVDDq~*$b-BB+($0dGc#}UziI4-`)4E2>vI&h}JQ^4@6Rxk4^MnvI7yxrK!aUsSjbE0?Kl-SQ&X#o(5x*a~pt?q{aIMf4pYt04* zX1TcHo#qF3v_@{*0WnNz>B#p3#vW0JBucUQ>uYKb zR>JREp6cI`)TV>Z2?+_n(qA%G(D!O@u@GRVySOX52Z+8*w&!ck&Pq^M@}v=v&w-0^ zlvyLQx`R~*BB*pBHFwBIJ5xMcynL1c*0ty=9;^O}P9jEs|iCt%JDJA8@N)Qx_ zgU$A$$|SqNtR-~9o+N8F7Sr7W2kVJcX~~0CSy@t2RyHv-L@Z!F1h5sD+m34GnOn?z z!;q<@Vu*Qdw}CZ7kv^xzMG`|OBB~NHZEAAL$x5BowAAuVnO3=Wjn8Z;AjsRs$C-i7 z=5o2^CXq#YSqlgXc6@>|gAmaO1T8tg{}AxLD4xs*R7pq84)nxg+ROmlVLyx?8#kYd zT9xpUjl4bz3-)XEKTv=Z8rxY`)kuMenjCAPL52r5qB8tXNZZ~fW-1)-k2cS-gJou_ zuaqbv+d^t&;HL<>v+9Q2B8Im_5@?v8FeGsk?HObf)KFsO$gu#xuL%n=MQl)kh+JxU zW%oQRHuGD-t*Q7_C6eY!v>6y7=?Ixg)S~ z2S@?gE6hXQ3$p(hy=3U1`5RRrd+~excO7*t^^fkox4rK&bhPJfMDX{%=}-m)A_uf? zSX(dm!A;)!FOXj*E7h*Rqesy!2#Yt>j+kq3cVJ+%y@^07WJe>36Qc^+WeQd^gv^gf zx7=a<8Z~Fc98QJ!?Lawa7>J_7E1vcQ zy>I+yz@Y|xy7|GQYQTanDPVubI@P@;ojvfbmDuYGk=|IbYpcYk;+W_!cx+}@cD5TE zboYrdnkHZW6g7uYu4RTyhQ@0eR6E?u9xjG(^upxh69=`YQ%ob#oa#p|NRi-YZI2A1 zZyT#mf#IH!3K5Cv_IN5&WR=JlkPDnxBSV=Hwnu$B{brd(9O6Kt9&p4mo7*@aXlUY+ zw!v^{*d+r8OaC_uU~|CEc7Ds4K-^|Mqw2-)E(1@cB*mOaWhsULG64Sf*j^a?oRGX< z&PRq`LLTf94;#r!`StaUR8OVPzObO3Q6$EhC6vn$<0$HC4vE0#!0aKA6T@8K6`7JN z>CGTdpjqE(gLw)dNvE?=C@wA#;NgqDdCjC%0l+8wf}bBp46|dk9lh86U$*9U~!)^munUdc%iS3m)g_^(Xw`d!*FyK-)FJ21Hhy_E4Nz5no-l! z7*dG1DEAcTw~FQX(lb9+-0eYvep^$0)8z<_wSpB>5n9baQpR6qRMm1NukOLiW@f>m%q_5@Uf!F&}-u})X?Ak{sK2Vmbj$y zKS%GN-;QLjAwA9)NGojE0r0PP9p}=TXmy^ODN%pCJzZV0BLh2{=Hgrzuo#i;?Z2#`l1bem-KY5$lF)GY&d|!?T=Ytf96$lzfcLQb5W(OSd8mC;N*tBKSnYe^Q8L zX8bwYY@r87j$`*yI1ecwv(^3*=~VC1BtCZLCNypjhkYnLcobhF=X=|R(63O<*{j6} zUJE}s6rdnN^Fq!y2fyJTE(y2?1ktgiF0P-~eVHF&2hpo{<0g=P zdShM)Uii0jy%CJQRnK&O6^Z(-`bOT6ta^P8#7grX`G zuTHC!$!gkv253&v30QLgBFIXs1yvXs61;|3)Qv(yvkjK|5i3nirVz(33BQZ5iO&(E zK=WB+H9>07>ZT+oCuVmIyf!q27MffQou|BG#mfB>{Rt80=&dkyU?fjc5oFm|l6va2 z@&^+_V*|`7kzHLR$;q#wk7^|{)R43{z?eW<;n$3r%lxEPNd3bVy1o8YXW{ zVpjZQm1Eae=GMK$s%-S77q{iWUa~Z`QaNt3D>JN-v_WJXrw~ghN{6(B(_{^;yo%}-WG$8A7irw zzo3$Fi%xm9V=UI&sB38%>HFDDoB+2Cad}ypg@$NqP-jQ<9~RUx)XO+72+BVTQgG}@ zQ12(k)i1*$zusM_^_Rux-&-ye<4&Z|Ya8xHBPv0>?l^nf<0uq603fFVAs>R*)#_l@ z%W0S3<%-qo^Ym`eY2fv%@8EkQ5ZH|j&TaG{Ynu)fUn*1FPN6ZU;@U2yqHo|@E(cTC z-1<>PT~A+KOU=k=5eB{khn%TldOeFOXsI&uIXQ#OYrSsGdq40MB=FjYpY`z9DEJed zzF_S{b&nwU`|!s7CX)b-zaXw4h3S+rJ2T}#$gNl~Eyiq~)pL#*H<-`eloCWq2_R(2 zSU_IbfY|3yS)OdA)%~q-z>|&52)F6~pNQdegMNP&6yyioKR^l|d(g30j=js@G}!z$ zD^H^uvuD?@e@}R7+ZZ+Uuq(@AX6Ag{8v^`)klCK~T{BMQ^rC*lTA)9`mFsh7)ZYbB z4Cz+o{wk}&<(%*<_#rm6g7W54piQmQdA0O&6PxWJ+9jVN|CA= zhg;CsAMNqy9n9yK*P{+ZV`8W~v)}JAI|RHAENazx-zrL)(m1S=&G|^0C2wcn;n%MD zbJxiCMGrJec3sr3u8~^~qaKT{s_z!?X!H!;VU_f*`ofA}Q6DhlEm@wtuK0T5zriS= zk4(@$*#OuNg15^z<0}gT#P6(iLPaVSXR)ZWZj{`MJW0L*spd=dMXv)L=ZcS}dT5fd z9@P2BEL+HyQUu-9Ib5~O2CNS*>^ZW(1mQDe7DBo`7?Z_*zjTY+9o{Kp%GEP9fAcy(*ejH67yq6yWFgiGt|C6KDx+KTeGi0d<)F=ZG0=;x#* z)J8*VDQm4X8#>eWGz+7h9=(g4Jpt^0J~+N) zW_(<3w!6QG#HRc5wb!Y&66%q6y)(`tcyU%0aDUbZ5TAMh|EiC#?_{t=jlEg+*XTp< zTEsw2&7I|zI$$d&kBejhl3C_nXEsUbr&30 zb$xR9Xh+}$f~-%*h9jOxOusFv(rIt4EOpq5!SA}cboAe`GJ5?bzV!rtYPUx(oLHOw;U-)4)U-;<-_}V!m{Ir^n?x*~KcEG#qz^YANMC?o4>w%`)X!@{ zFFT%H=cA_q*mtwHf1ZHJxFKGw-|JHBlgJkTCzHU}rP_nXwFj;ov(DEF>CW5n2H-oS zT6?}(JPo*vKlQtY0(Nk!HQgp(e|yDe{*+~|R?{TKi}W7@UT(B(oB7PN0&Qm3))>nD zad{%?I=;DUFuOlXOuAW(37RoLgM4|z^{`4(f119zxUpIo7m1#qQ*V`8JjWA zS)>^LU|Y6zVtIO!2WmW7M7&cCB3g4FJ^Y0z2qwTB>gR~31 zksu5iF}5!_%~K2VkT=keHXW!1QY0{Rn4_HDCk85=B%Ah_bP1ctHv$*%`mkNtDECoc z{o_rG=*>TeejJj9mA7s{ z*}XlAc2-}A4w}8*kv7DB=}0E-o6fLq!kb>v8EjAGO!)?nLVmNE?7!Aul-&v%$s$hp zNk5$`3?!1`^}gGZjUrbYr430eAd|yQbN@UCAtP)-0!2{_r$?(Iurd@-Y=ftkWammq zW|yE%R;QgnolM3bK#6wg@`xpTAGFE3At>aAIjrfbDc zfI6aV1JV-Kig0~q=P&|2(4rQ$Uz*-?g(26SO_eC=XBLJQzHw_}2x_{qY25WA1J zxZEPzAWoCuu>*@(f=n)B`JBQq&d4W^907gg=&HBEW=9a?fpT1|I<0aQZNBF(1JfzY z-&@UBx9_``WiPFi(^3bCd5Yd;ez4fs-2d?IM-8zy+b-UE%0g3lS_@p-Ea#_R^EQcZ zX;Z$)2dR*k500-HxnW|@d}$vH`g%!j1u61@_UYg!rwHz>>=G^*x)${~yO6qaL)s-F z?dQS{!htVO7Z(>z)~%KVz&&GZ4vvPhI?b|@XM5^%C30bN0e91?1Na^77Yy9T-lj4A zb7{}ljoOyc>Fr~oPRFI5AN**?tKMD{zp2_hh*x0k_*?xSC+(MFPv+6KfKS7qhaB-* zgSZKQ{f!8jJn_uE)Ju>4Ofo2|GBp9AT79 z+M(z^2ugTZ9<3pWD~E6wVe>8 zNz^zR^`n-OAFIAM0PVhIXmCK35mj9h(>NJduTTV&61I)rnj(-4XnD6S+h2ke>V>D@ zPFz9EA7Cud3)-XUifq_S64H3(IzU1^Iv)Qw3vke@@?~bkvcXE(N?2LQ;9VwKbLQ#X zH7CA=cJ1_HBvkM~Q&x-9Zu$t~Qon4Ih8Qi1F?txV%nAPqIJg4XhquRTQ0S5t4s#Z3 zz*8n~-R@XA7a*LkF(VoojlWA;3;#8X+1n?4wRL-6%E)whc=(n`O7r7LIq$=GX!0JV zg1x@PrQ`uh=^tEIk8JHRq3kMZwP+g+O-+Q*9&`5DrKP3ml?){-f(jSJk5Ln0X>}n5 zBFrSPr4T)F-so04NRZ|gO$5zWHBQ(}Gx-HAZP-(-udI%Zx1^+qVW))Nr^Aw)#uqPP z_JiVq>)^SdZ$BF?7E%=ZvoK0DP>^5T7?X$1F~lV@vC{}-khbR#H}E3D{bNCWUd%7Z z6n0BDkI!RxM0dnSl9D?G4C+PWX@-V6m|q7xPUg>N5|t|q9`_QJ1%bU+rTXzPh~4#6 z7fsCOlC-uv zW*m06d^!_&_JifkVOZXhlR+|&UtNL(Bm6Yx;#kJYd9V5lh}bqH$KZoaakSsC!5$<> zGdrbNbYWW;+ri^0Yr8;~xw@qTcB!yV@Ajlu8O}Gj0`G$Ph3;OKgq=>`-@=`?-rojT z4V=QA>Yw{|w|!iGJXu;rd};#zNDu!j(%n4&E8W?o=hJZZ@-^oAt%KL@K}*vb_hm|J zt!@q{PJBL=1EW`!dUqyH8iB~TWRQ2Pwl75&ie;X_P=v|)v8gfOs{%N9zAO2usi|G( z{jZ0lE1aCbeZkBR`YGPmoiQHtuyHz0+JEH;+==>35LP|B3usu8sK#dOyC2PGfy>Vu zkAbi1*?y;k!sn~X0S_J7ov$6)_hY_7uCM;Lfe(VWf#BxTI=QvibYJ1iqT84Kq9D(} zgT(Gr|BFPT)0df|ljrU1)yER$wa23D&eQ2^?}?(*zzHj%H|@7|+5T(U|BtJ;42!bu z+J=FlL0Y;yhE_ouBxML`q#J>uyFo&_1nH6xkcOdCx=XrKy1Ty9>yGz+zMq`Kz|6KC zvDUu!fcLv<{duZww)osC+Vt#`6|5meoYnH&axLGCva#&z%bWCeK5pLg zr{849AB<4?H&o%aZ^@Tt=2u?2q?E?f`l*Mt(MNglNjorr6o-B%2T?GJ*! z7YDlh?%3lokm_QQdZN^Rs3`y0r>P&wg#s;k|F~sOIG0b;L~r^M&^Qx}z?n{QNu-m6rW*C@z( zX9{~A{TWY?iKZ0vg7FfdzXsGV7FP$KR2gSxeiOb7jvGnP4~;K@hH>+f zgcB_MA-~ebL$gbl>m?krE<&iY$!m!xb0%|fX$}mKL_AnK3B?9Y7p;O_E_CbsmX)n| zZ*M<>u6q~KSbMn%WRwVux?o|_$12J=b-~@C&%s_|B1eRW#~CPLVv>vcdatxTLZQ%c z%PAK4&>!nKi@}z7B7x2^xo4`c(i)c60_qcFu(i@3YpwpY!^t= zG+(Jgy{-?1y)R__ggAN=_eZdPDqt3iOaHSl{r@SU-48LTkE2JC%Ud#Rj9RTYwYfrp z=k_9?9uwkvIM6iGLl5+yc17CIe`}Ii=mmZd(^lX^ba`u@tKvpvS^zQx(hkj25xtyZVw4d~;8@}|IA25DNC#F- z&xcsq--sEUx>x=U6P~IuxTnZF^vl zStylfsSy>O>WG-bPE0wjBW+@F9~UtMb|mtoM`*P1+-;KljFZZ_LjKhwKrn0EE-@JD z=is<<;ECZfr$F_%9iVnlJDee0BO$!lNRbco3sP3cAd-9Xea>Ys6AH&tkW!UbbZnYt zm=R~M`piq)4XQ#W!X*xZ5&|+H+I979uqQ*GOWd$`RZ)#i59$`9Q_QqbgAX4*XzFOH zs0=_d^mgXwbrzIrudkfxe44i6uYO91u}e@=ZpGhqaUV93mL zn7cRJe!FK%wMjoO1oXFfbmZ|TBIK%(xdo<+c0cg3xYbt2m5;ogTV0m>)VIjNUywFH zM7F|k8@`5UojW9)Z1p+~nF?=ku7a)UzWcMhm^6PqlDQj(XL6pzeQN^*ttIIn>BNiIsutvFg^ja% zZZ){t-FxHvV!9!S%8HwUEzJkB$Z#+fn|8rjQa=wn=kw`I1s$(9$6s3Od9T*XW_u_e z$y|HusN^Y*>=y)i3k4?&M;JdU^(xhLM`wJYcuOeg>9{H>bM|L9;2rcaROyNe8daA` zDh83UsM^pyGnHAN-3>^vz=vAkGbu7e*Y(8IVW;!Iwm*a9KI)$7Dizf7Ru>9aKZ&i6 zLmy*oql7Ej@4Av&SDGj8Z?=~$^p8Azm7EQ{$jjk|$R=m}+4*1s9kGhHShg4)JD$A2 zZigR^MD5Qu<>-mx4x_(H#@eR8a`1LhmtrQ6B$CCJrB>|TXxudHmq%nMFSx*^ zAPH3Kno(CkE-9EG`^An&VpR92uzLz$s%xtx+5sjV_b`678 zn&^xrF{Bx@Q0hy~3RVy#i$#M?OlT1LV$34>y5MH1(7mI-Ewat$2w{hTfx5c;M+ma% zQAC9NI>?dlXu0-&wsPatT^FfjFdux-zn!#=6hSzWB3uY7Xf3R>*oU#lJlttwi(Z^e zDv4J6J;Ueqr;HbV&R8|qZ9gANS2o4)Y7fyu>Wn0dY+W7u(y4&b1mECJ1VaAYk-E3X zl2Eu<;XDUik3PMJb)y-)b;ZTSb#>*XwTDwhlPxVRBz*5~(1NQ1pCahu95#9t0FW{_ zckyzfaDHKa{v&rK01=9qr5nP_VtkHohb3IyqF9Z%k;eSJ8O4!L!d2R#hB$e;D%j9o zqS!#epjQ+N(4itM^9gN9-m_ev?-hFiOk0-Mc9J?Q?t%0`@yfo(i6()=lMe2J7l-SCO{J} ziL6ojF_`?pn!<2vAhEnQER64;U1vuGGGAm1f`7Ph4<71*{Q8Te2@o4TCNOUdeLVS| zGz^0PBW(rw=ZDvUFz~`a81yA#MbvgV8sWlc;f{FGKiWfZSc4MdRW{c4sBih?hn1}! zeps?ORB_uZ*y!LTbtgrMXps(`g>zL3y(D+9>JEC3R{!DaDB7tz1fCL(@fBNuk>Ni@ z#WE)H-QA=+W(-+$tXuA4oKpvys1_hn}biwR0FS?$Fy(HU$MkY^B}99 z%to}4*d&W^rocC-;|%eKh}~DduE;@**N4*$`|1Yc^eN_bY21hspXhP3tY*g?Un&Hv69V3=F`sgMO#JTbK<*4uaEeJO_Rxq5lin?Dy%%> zSvcf^VNtD;$gwFJaR}Gsc$p_c?n0yTg}mch&p__dNQSVrwuYIRl)Tf%pOBE;+=`~P zm_Pr)0?aCzg7zCS2^v6hdVJ>c;=W)LhLaLrf=}W#&B{UNxBf(Z21>sXs^hNPf0il| zX+&nc(uxR-Dgwc_JZ$~I^UKRHuj@=rn9Tyr<$ig|3!6%0-ggh8WM{yWNSJA%ohN{k z*}jaxg&(Dx1xe=}zK!C_{*?IfSWwGR$6t?87o_Vfo&*h*kV;HcfAQkGwblNKs=Q3^ z91zV*KHK&Tam{iy8f_10V-Rv=zIM`pLiKnO=d0b00aMwTa-HkH zqeUU-it6gg;d;x!eYOQ-%iBoKBl$u)2i~PwaS5-fT3$_!n?JNN#Z2 z{_Dy<9hqX%8+j@C6c8Z!iZr4ZWfO--nQ|!d5;*ks=06Tq1IAen>8;C|Wg{b6;7rA) zvu?2i7F6RGv^5ff{|Ca~H(6cwI`&a`0aWWE0Jt_Y)rCm*7v2xB4uG!?;p_3g>`6_V z{(Hw>cKb&$>AMdEGrjXzrNPgR;Nrl{I=r#T0xsFlV^D*LtGBmhUvqqCbe<&|mF9@e zDxNzl^PoG$jv;j2JNzQOD19FXla%JPL=@PX+ad013dLetYWwr#7|XnZz9ibD5NE60(o;)X}k&7|#wPokU z;ixqPi<*Wf1@g1}xa)L-({}mcLRCrhc5`x6sOjidf%9mw_WC9amU*=7ED$9-M&@ljr+%@iq7H?Pq%Wg_vIRoHOJ_X1s#xwQfrmHgXH`|FK)i73tA@Oqnx;aF(aa?s zSaX%#ib&pxLqBmKzwts^E`$e%YQ9G*k4e zQE>*E^sm13-@WWAt3`X~clXN+y+XDV0s|6v!k8ffNJdMabT;8uLF$CxEmfV*)AhGu z5>4x{ABk_x3$N#PTgpR(UD0QuOm(;GF({s`lzVsS3GU+Nf6RC&(QxxNUvs?*=b-t; zmG|DGCSkE8{l?n#j_3Sc*Brk1*CX1@5%ToY%fCVEJ+rxBj153o z{fa9YX(SY*tC=lID?!l?p1g{Kdj&~J&bR5sq=5;|gkMhfM-O!*6g@Jjcq!$%QYhI7 z68JgTIq!EofuLbdK4tr8(yFAiR>b0nn5Suh#&9*8@F+G?iS6CSEQB09Li)5Rk`mmc zQ3a|+xw-b*pFYXS$?=m*bKnmd#eqoJVjrS9lCgL+`Q31$^#?+S}pu z38!Ptb{)3jxlRule~Wxy;R4k!Balf?pigL5Nab~$e80EnpIV&Y`E<4!GH&AS``l2y z6I%^LO!Wd*NTWcsS?|5@=r0%8?A6=WiHV6Mhv9Rgp|%eL3czh(Wo0E%GCiHZ*>*88 zLLmqQX{r>d^$gSFg}Do1>EKj$&;Cwge~G2RKr9(_;ALk3u(L<1Ed+CYd^#^}U|XE@ z+HZJmh3n_2!@Yg~HlX~vrgKS5LNiF4kBZY~*x=A)_N-ANthL{28ft2r<~+&o{QQLC zWYL>to&EYI_gj+k85Stdhdyqb{!_^O3+l5rS_M{XQ`gDu`A%E3Xt7^dMj98;EB;;8 z?h2T?dL1`3S9_@yDW(5B`tKCj-<0~m>BN#UwqHJ-z;L6Z&6AdM)ZH1IuS%1vAhPIY zfND8#s3lQ^{NUd6$1zplxE-=FHlnd8ImtK*!uT=DT`SR_d)w?|!GKmf;D?=kKVp`L zxM!}`p$b}t^0Vs+4P=!|fAAjbx7nm>zTSlnINnM;hf36Tlnf+2|VjVrHC2uG+5#COwR}V!#7Qpth}0VIs#m)KtiS!If6w-BD=3 zwTH(kDXRdhnjRyFTQkLZ+2+%f|+1Ax5v<<=HMoN?BMDbH?eW+rTU z8Yo1XZ`LEo_(Yy3A~+9$u$9Gzqpo&1ymH8AUg={vPYa(wvy&d;c3)xy8)D;}H;L{0 z`k*>O@c`xe9HXhPED08ntZ{zNNa*RQNpGg#)9s6WKA>7EEv;qGkgM({WCedYJVyf3 z5mM81PBtHpnvNUqGn?#y9FVCWApw%P)z!T7GkjeBx*p`NI$mCGN-_H$Lzb4@l2ALB zkQm{$%gak3)$Jk?*m)P71%SjEn?4~4DhM-~n{!7rQSCdWsD}~=E7kWcY|wAwp?Kg; z)POOxpOu-3Q4w|H+sHrf=EcheB^bkfK#%O9{}e}tO9@SA@9FYqCiyon;qRKYm&2sS z2SkNcnlGeg2NKx~UJoJuhqj>J)2_p#mjLsVw|w*Yf8GOh5{sB$iSB=!c>e&OK=k|Q zHDN!L=Es$_AM&qP)W|CyPkRF^kF}2E@OCjqQJ1I#cJU_i@?M=)RiNagZ}$7!B%Wbn zk13YARLGDwpVSv@51WZuSXY_+Fn#eA5g&gI1;i*(&=2dYoP)gr-&*W}fX|2i2}tA4 zp0=o7G0$SKG5y011G|DTD%eqVAVa3SZwYU`51fckVclNw-i?kn{KB5W^wuJ zN@aLpYi`9xHTxj#pz&b5$t?xeEx2tMvX?J*yVV(R zK+vxl#1zCP)Q&GZ!KA7W&OAC&aJ?!pMw=NdfNjdX>Cr}L$tgt?cthL+m;C!dLcZWj zvV&Citc#R4wL@7+VeR8Z-1lBU;~iF$zq6)6-Ojd!n8T%A%Vh47gw8FX_=y=!((1=Y zU4q4*^qVyDJmz0spgfRPMP+_JdS^ueF3mwP4i)Hw{anw@4Y?Db_t_cE60}=R2QnIl zQiQ#FE{YNu706#(smamDD;5e0j#9k!biU4P0eG^rEf%Bw38mwf;Eeu0hJMPe5n9H) zcK-LxDh}2?AurqOkqk0Ez#hx@ zaj?AEd$TXDtc*jK(}rAQ{rVoT+vu&W0ZQvXNjkPIhtms5;1(l5hX~Hd!mjH{?P2yt}5XiEI_R&khp;JDX~9Io15~yLD}1g?J4(#*^nsIyCZw_^xj(*`}Q#)oAZwwf^TmnGy>W-iiLcbYfr69fQs~(ljGh{gFiS?x%TE5ByW|n@V+rHk&R)|h1ja3-Kr+qH z25B)jRojHygFNT3cH$hy2jbFnC-wWaIuO)9Ok_AL1q1|)l$GI^LYuBBXB%Da2RU0D z4UeB%mqi$_gw~8r);I!Sk)V4Vrlj2WDtfCpjp{HBeQXcY2qOMn)KVAOoKj)BU7B~e zx8AKl0qd~Q1E900F~*vuAAvNa(z=z_y(>Wg#PHZx;9KSbABFR`epl>;K-~M809zRC%x8<#(gw;glvM35>NfumyeFkszADx zT3RUkV6-Hy0{d{3f&y3Hx|ZKTU)(?ZE>*zrWwFr$m~|6Hblca0BVK|R-y{RHl;QzE zX2Vpj>#@*s4Fo7GT@R)57M8VgBq$y)Ro?@F8Gm|3@77}cE;f4o?iT>su8W)7;99co zOjM6CAmsLaIPInK{F4I=kbyuKcqv5pObfv?DrqBH{~CJ@HMFHZGaSa80aOtBcXpOH zJGz({)E)HTBtt@fs~6q$bhV5umefky|F2pwT8$@04VGZzyi@R`KLEDBpPWw$^}p*3 zF3j{^f0xW1S}fJS#FYOzo9zJ;!Nw|C;k%K@lK)@_bGOr6t( zHWX%TRug3iH>!kP9T5FF|K$GYa+~w9U~LWF8Q&7x^tCh)rSENUO|Ttie-J#0hZk}C zOGb&0Y@(hbE}&mkeJ>YDoRpjku4$m4QH#)bt?LyOXo7LHh0L4Oe8<#gK}SKTH(CXU?=(L2aiD{+oX0HY`j*XgIXCXe zKhRspCDw%-A(kqSRoO`7<81P4FdM!Mu4k6*h7S*VOK!O7J$jYT)$$Fi*MAu0JLkdi zf+KU|v1xNPXF>;h+?USiU!F#wpznaunMdXc&yYT|sb=E6j5^W)9dUT|&=0NrDl8|` zI3QwM^i>%FrZH**NnKsx_08!tz@LIHKdb`^S0K;J^#VBhMmQo$<7Lqt^K+DC^-X9s zp1*ua;h_CPH3skTj)+svB%CPx-AgW&!wME|lal-Jh;V=zArl2y43%_qTdDb&Y|Nf+ z=oBbs@K|q0YFC>>Qh4%P%cghueSCbFqO)eJy+)7pngBfV{(htEVWfq+K_y_OcWh=| zme;+>XJ+b2;`R}cCU~sf?!;C59RRE-kL|yP;gmw| zfBsaO++Q7fopvjXikuGtTcmO~cqfKJkdz82Cf)@Y;_$|`znV2Re~Z}DZs-`nC8waG zOrvbbXwIn8$I^LWU*ar`?ZY3599KI_64BI`28sb?tk9$7h8$oRaV981bM+J9R^6yStp3aBd8A+L`rM6 z-h3Y;3K_Ad{autZc{q^t=|&gbV1Wn4Kr25`rc9!)0oLjCS(Q@=UiDmq*S{-<5)avs zJD+#?)M@t{)(fE~-n-4K<-)paTLB#5>9|M+pErczf;VFy zI$KsO0196p=a|l$>Kzr@UB=1!y92F*>i%QYws#$HgL!yfyI;wUmAW@bHpd=C*j8hM zVnDta-H?#nM_b*3p|Pa#F*~&0E1b`IOH1FD{hFWW0Nl=C_v5|eOu50{!|w zDj4C_9TIu*{Q!9ND>Md(lzWLb<}zBgaCSBbFjV9)>BA!;$fTpJNMxK?M> z-tk7;5UE-2c>fx;np#uTmKOD|(O>9zkG^m+<~v0he~DRH!=)H)5**lZc2))Gzm+ls zJk}l>T3()kOPKzgY7#pfLqZdFNWJmAvkYBxy&$vC>j9U{qwd`}y9qR*8TG zgwru=Y3BEuSM)pnwC@QbF6q)*{hpsPk0?#ofgHKCRGqzj84rnrrRwX`Uf=V{=X*cD z`&kp9fZ=z!gYYCeI|wEW2cHD(py^)c+K<;KB_-A^U1i=Mc^!NCJzp(oDrE}Q{%p=I zEWh4jFBiI-HEDbJUG8_gFGG1hZ0Gm1I@)@lVfVE2{8%UK<%&VWsF3QuaYkm>x-|{V z{ZImd{d*=vE5-p2??~>^mn($dFSn6x6i8Nxli6p=b9y5N_MLZ9uW4BT^R5Qa%a>x~|Mz4Lz$I=U zb)a)EC;gW4g?EPIgDCI5wcKPMySODORY?@-}$K&}tbE+1|*ZHWZG-I9|4(yFa?C z?0Y2ziise0mn->~!?)z3%OYdrDUp|jD996_(~EX7Mv>L4-lbOGIwnTksHRGHMpF6f zZWX-8-Q_2U($L|7Q`7z8@{a{Fr~6zIYEt>$JKoCr3+F4dk;?|X59OR3>!3{J#={t| zM^J8gR%7lI#rNg!v2Yc|MT0cScS0l=!!W)k~MgVG$>Bi_@to|=RcLO4>E$!&j}yhMuKq(%IW z76k^sC2iOfxC-P!;D$&RsqF!}EwAgN1wcimk_jyM!=v5P+s9g$+Jr(#CZFF(e160? z4((TNBL@!b3Q|&1JPpj}aaHW8prOxi!tL;q4a^9(Y2YPFaY?;EA4lJl*yCud@hfr> zGjnhNRtUDFg68v6EWG9rJ6o$jrbI}l$Zuajma z2Esl@HhbkDoHLlO>t=}jdPlE2qriv{Ttlbsi#z91wB56g9m=sOzu&z3dGwwvSm5{@ zpRgM7|5O$ysl>#5wz~2W51U*+e-A`FJw6z96j=VJ{0KNF;riq?95(*n4e_@wDdRyV zWeqQWALzawU3f0b+E0%I7V6VP+WJ<^y!!Pe%G^F(9Lc9(%TR(QgodDp#%Wpa&ZIHL zm$5FaF+WulQ#wO)G5~5+u&#ear@sBRtP@vjT_}9aMXk82Y=M2Uq`+b35rpzn1eJ9} zmZdzooqRffBEQd@xw*uHLwV`%q^x{Y^4FJzaqo{0M=vB*g~y%U462$mb2#TFza52} z`zz9Kd+{!{>>g#Rx!DyrasH-IxR&HSag&zPjP$%Ksxy!@p&ScE^n3N0+|O<(;i-OU za#Z+BnCFsDitR<1?^qaVehHHYj17reePtCNQyXTZt!kpmOdt_Y*>KfM z<@eB$C3-XF_c#LpzgUz)V+#xW9voLcp$qj1v1W78s=DN#Y^^>5?yO60OFn;zPxam& zs;YF=ZV!LfpgIN~fH6B=XbjJNnhxVnEr-Djx20OoVXH2ns46OG6d*?BhYcs?Gs-6? zBZyBew*!V@+FfJztUw^7WaQyD2xIQJrRGe77MK)^DDhh&ofFl`eG2OuniM>8jNgJsTHg;^47PM!6$PKfpAO}Q7`w>cN%%o%jz6qmLqPv!v_B)^xzQV%?3 zZ$%(bTl%Q759@rvNeh!n4LNO5$27f`UJKiN^MMQ=Iexl~X`-wT6+zcDBxnnbuoJKp zs3HuzdF#c~!JmBzYr>kB6qONbwH+5SW$*C{I z?zj;d->?zhqj4Ah&W3m15DNY>&I?9jy!b&df@f-q3R>+kUNp6*Y0k{d1dhn#xu)aQ zoL@VQ0?=MtazWRt18v~!11g`pdWYffd)91hfSO={wi}=n!NGfr(@4*}VrE7_sH)4~c4=XI}tu6pg;>|MYvS6U1U6Pnljv0n=;1xuEl@)vCOK>p#H9 zG92gz{^~`|8-*1iWXS0MIhggM0v61p#l!JfP~os|0nVKNEBijCOLwrSiVT8+u!MjC zbE(teNBI~>t9ODJwO8-t3*}WxB-w>L2n4oXF{@rR(GCB)Sr$M((}71;=I06rxNcpY zus+w>Q|;(c*N3DHEtrWm>n~Gn#Mw6sgmZ-Qbk-ITNu>zN`3T^ML>wn9Nr)-@5aAPi z(EE;w@>jezHK1gYxFJL>S6BYQ9xZe(xCm$jxa$YyRGDT|9O6f$2MuV7u&$EWpm$@igyFOuG*z_qubwzt7r4EMIEwyxCo7d3m|jY369j>ncOo zYuX{5`#YKWaB747+QVG61%!A-MMqDY0PEIAWHA0+1zxB6XYoj0T(&VOLsU|s}WxVrVu&(8;NyVl#JG`ohaBw&CLL&jJ3^Jn^( z(e&NfDziaVCE?o=gQv4yrYvAWZlR;&WngDV8wU}O@7DsmmYpvG%C#%1S-(FgYe!|2 zcpCf!Wu>KAZ=gL2Y__fEWlfr8A72B!P=MYt2;&84alt}f(ygj5Vz_Jmg9Y&Rvzu~r zbDJUgHW$D(%b66k8|u9Y0ziz*YbpU}%j2iVjEsy~$4kJ%oAk8+0XmOB=Mtt^1$dQS z(BF%=N?+R!>wkJQeQeOp(8-S9(@xe+7n8{I?Wo@>h{|19FZs=XR~zwwzo!+?l-c_k zLXi3E|2_t&NuJ;A9ZKC-id zkyxhI=8P#TbjC;%v}12W$b($TGECrXw81L7Q*%x`K-h-I=1H8Bh2Vq8^PdpzNq|D| z25P0cF$;89V(l@Dc~)PWT8rCpxJDpCl3$4X8oqppHw)}5>7p(C(2aBR+7KH>;*40I zPb+DXbXTly{skSjbm(UV8Ca||I|pl7X%UH_`4B(>0W60kX&o9L)vT>`z|&bDShOe7 z5+ijvkWT{D^(rSM`FRr%-mMyvOA=N?W^~?Qu7yQLU^Wt++?N!yGAX9l+pl&0jsLz9 zv~Am(*4Y_J(=;%Mx$iHUEOa}VJzaF#_x-+^Vv*^yyYFyPZj<+qi{4vec~dhP)E1@O^;0U{oHXr+p@CL{=d z4e@j2|JJ43++0>uKaBj?pNGi?FKqr@kj@B!7@>Z{2J3?`vB|jq>AEzjK~!zluoal; zh>FOF41$=%k1CauJ(TC$hVOVqf#f0HOd>m9Q{3rSKTNIU&5(BIFx#-D{h)7o<*Z`~ zk&YSM4ZpS?{KJ+G-(r#-S2C?5qbIfLiyk0FKo~kdr;D2|fC;40it>+nQ4-tmn+6S~ z4uTZW1Bwx{rD5zBhIgID=tw^s9ap9b#@6M!F&GI)$uwUTGQ6s#;evItU~7xqHg=Rq zzV0%GHzRe~#pIHo#si&RU8yoD!Cal4^Cox0!_ShE3V`g3*U==Z+8-#tzV*6QEevPS z2&AzOk=EYR6x51#3ac!ip4l($wPm+tW$H@*Jrvo`9rQ&G9g?-8`B5b>?>L#;gmvoK zaeisi2lkzgG%8=E4}Xx$NtX~gSx({_DecQ^Es`-f?swK&2TtD68(dBT8SgPM^Hf+C zUxOpw<*fc0{|a^WkZBNpJ68@{J_FsV!)nun^?IK3*SIbI(kZ zWmN5_$2>zE6ePYgheT!D_u|X`v52kj{i+<`35N`a?RZtGMb0}1#9{+Bm@$;Xd$X=S zzkZ-4Fh)a3$GrC*IwPF-7hT^o@YKAf~cGxiq-CtHSGR_FoGBneTUsn83)4Y^h1!c{%7E~zbqs1v7N>jp!{>HorGe?N2L zfWNoDWB$nS&_1bIJNSPGAA`MO@_M@{b{X6JgjJ_~Vtc*1T>j}9RG2gk&G)h8gG6}? zan3XSH=Uegzlyl?-q%f#Dh;TO&cQ)<@Lzl_yYbPcB4G5J4OvQZCl?}am*if=;{*#-Sac{VPeu_H znKc`p81hpbcrKQAgqWra>oU?&(>IPOKK-3|%Nm|x-1gF**|6HAa=0rp1gum6_Z3}C zS6QqSQ7AAH)68F6zp>R4Q?6?oHVw>2`BfGd7hgGa-V_v6zIFlmJs0qyqWBrg{ZQRd*bg+u>jnrlVl=12Us$f^*Nxix!S zo<$o6o|JZ}u&YZm9ui&Kg{sdv$^aKeRdq6wWYVtQX!o#0qh*8QSPb27`2p#5tqTB4 z+yJy$n61ZA!%n9AgM;ZlpWU+epRo>rf~Ky1x!7=iRKJ1MRbFq_{dejHsNz-dfPoMU z`c}ne%>$sV9zvlRdU{39$dmEq7$G>@AyLwjnLGJv=$Pl@?bDyB#LJjGz3!vQ1@Afw z6ae$-g*H0_T9L7-2_EZdV04lSK+oh^*vGw+U(7;~wb12^iY@{&ur2QuX3YP8@alh& zD>dYy?}74W16_I^n8_q0|5C00MYX6&+;`Sbvzop1737$oy+!R-wMA9rc~CwId?^M+S&255Ij3rlI`Bf(lbf^aEB`1uKcmORjc^kWFe87<((E zBZ#_l6c>``PfHDrn9c2E-QBbyZjmO5S08Xgpj%NI8Ijr^;A!8nHw&9o-eqHlLCMa= zS|fGgQ7t)V5MT1llb3%Bh}^&GHMUU95Wm*j(U8QG1r%u;$CIeG=FGYl8a}C z{1+9pRW8FwEEp~Q`Dbz(Ir8Jy@rX~|{dO8gT3i3}wCWU5m_6EArT3T05l#u?`Wcon6df&=g;!G6QOOY&l)#E{P!H&@t z$%+)8D?icr9*b82I7R>qA!L7yVd=IS$JAP!E%LV}$p%Cz=fCNp1?j8j-h_LbTT-j~ z>v$NQB`dUpFt~+Eir&IiF=f+16+;-R7$%&k6@1pl`CH%iV+=Uv0W2F7XmSd%8x7K1 zlApRiSeQ$}yBa9A2EVdXU*4#tY^MF#W77Qo5xY2@jQjIQb2m|}w?Fs)V5UG3_#0D8 z^YUPL^9ATzF#KfLe=q7XS7h6a*H=1J+Lzz1IU3B&H!gbi`b}61Ba707V2%T`#YHB? zjJy-fjVWq!trN@`b< zd|YxyRb#1R~wIBl;vBPD_Ht9EgmbPh~&l5pl1(@ftnh;e|?_WaDs z@3}duD5Qs{yT(SKzXfz&2=H0mglv^yVmNfJ(!lL(f>^96J-Dtp92FfYI zMQjs$L8H%>f2b{;G1~d5Jn1bDWG-?sUj~!03^+2L#ritu(CmMRBD@u^+!kx63L~Ja zBV}Q!wDF=&IXFCwkTU7(96;W#2V?$VYggHzt~D_PWB>TSKY>n324_olyf zN+nIRvffWk{YVJbjZ}L)Zryh~ZnPebXNX{gkjFeyVTJmLWPJyULvANK1FEXa{Y;(f zi%F$6z*qyyq?1xwbrznQ+L*geOSa36LDmJc8N}BYNStVsHe~6Lj*vEyiJ|Z~?H*PV zdg8ADtmezgjvD71=c>2CO-8#1bG-8M^4#dv>4Jn3m}Y1&L~dMtXUGH`kz;5);Ri$O zQJ36dp8ujb|6;5Ei{zC!0O#x5q*7?UZAM(bN6dx)FDc94t9YSTjDFLA+vLDtv1zm9 zc@(;Gv$4>28YkF#8~1zV;m;nY&mY39@1kck!sT)D6Lc0z^U_svnsOOvBT5?2(5k!R z_}6|}KW`vM)1WbVxxCP z{X}VwU^31M&Tjh+abJR&O<^pBTSASMImT@wD^`y<7fRLOy}E* z`$jy2JQ4fHX6N0BG~32qAkwAfv}dc@VwhCW^;KD|S-%%nBQ4`-f}imk(5s+ekpq4B ze#nb`1K-_l08+~5O-bEL-7eVf>FJrMQjvdhjPnjKEbF|Ctsd>Od-fu35nWyxcQAhr zc>K%6b=W}e19WSijrZ^0zXu3n4Tfu-MXH6bB8#yRA39ajI0qTryhu+{5SXz2eVMrB`cJhZ#4 zTIe|+<4TT3>WVSH7cLXSCKT9@D{D2rLIRq;jkfASYMHglM~E2^SR>N&avFqlX^kQmaH2 zE3!T`!&MNFbYc!`i_;;Iz||pg3(f5rF_CJRhoFq3e|;&GH1e?le+QHH(>gzP71L{! zcmsB`?c54Iy|oe+m-n9!>ViXCls+b_b87Ljp*RytsH5%zVM8fA$#swYi z?nhnrw(HX4v9Pg8u}&Rd^M2-y$K2z^Mt_NFVU5;<)Q&^j1%H8zlpu+{laIVANY&?w zC4iA;NC_9k&ybkWenjihH=aDZ;}qBdh<_ZtVLJ^V5GYKt(@8iYj2MNa0fM>N9~3b7 z4a5=NOu(N9Oq4ls05g9hh^mQ{6a#M7{(%rc*joD~Gm^dwOdZcA;-W8t#0U*g1Ki}! zZoT7V^9o?*6J25Qgq#hR(>fZZx*q4@FVA;IGq`+pftWT2hpyvu9YS7wo@@Vo51@Cx zzdH6?3s(47sF6su_0Gujw8o5!Oq5l!AfQ^1!YF{G0nXw^I&UiPWYQgYXKDIPCtO;z z{4V;&=|FqsE4;<~BKMSuH;MCsM!@;Y3c<1!@{qeG3CNuRzWmK6jXLrC~2&^)*-m|t8J-i`JM_|s8@US-TT75ZFn|4Q@UaTPsZ zU~hw`T)A6df<0Z$Whg-fD_`9Ezq40cl{JPp2xyKXaLV}f(E0<8enYxFJl?ylDUn{qqPb1~`qmR{{GY>nhd!qL*{d&JW zT0F}QGJZa^ZBCI~yheYEDZjHO{l zyz(zL;$ty-=n-jBzWR)@otwPH#||``kRv*^Nv$#bDgo-wF^u4%vV0x1Vee8jsYUdY z-A%&R-ONpm+7j}mFekV9onA5hROHuBQ(VZ4kr0XN>uV-FIo&2hW@7YU1m7$mb?b8w z5BB_@0Bu5CY%;?nDdKMk+cX`wiX8du#%d-WQkP|e)@pi)sH$mjpGGlcXxNGaL!=B_ z39CnUZYnra;=U;4tE4!mPF-o7lT3FKtl)scHE-ib_04q&%!*0_uLbE-gGP6NSa(}1 zD;rx|U@slVU{e#dn(Ze&-d|r|U75$8;~@}5r?3OmGYKbMLCfAMplAmiszvRV7lC|? z;S519-9dhLZ;>oaItez@Eqo9tBSR0E=q^>c>^qH?daa-KW_j%G0tp&45e8~%K$LY} zw|vr~_xUge5&K`&z1*j;;5bv&PVdvI@ZNP02<_rC(F`!b()avy4#+JY2L?H_1Y8D= zv0uG@&8b^+Q}B|HQ`_;^W|3-mI30qur0N72x&XGj(K|9-D$Hbc(*M^1{ii6xzYr@jX8n3? z7VshQX+7J7ZUs30glV-XhZ^D14Doi57_-B9f|S!x%%t+)$Z3qr1`Y=`6FZmje2b)= z3mT^ocL>12-=Y3Ye7i2&N2)`Vyi8ood2K>6o0o?aH{WNK1Z*_zF_eM%SW+Z7jgA^+ zo>jFJ(;1IJZSL<HqbH}QVbFURg`itaZEdgdhaq&23kfb> zfp;cis74F0(H(3=1YbY6VchC8PhM7X?pt!AqYtac_S=Li&5( zmJBV!@bHFhz0vL7YqzpSli#`B7<**WNC-6MIym_9s8xA1P5o4~Y=)={Z;yLC2~35E z+@n4b$6_XLRB^Yzx3?HmW+*z7x+Lw2Q2wDwZ)-w&w#BM6iC(fav}%HVk>)DKhWW+AK8=V}IT z^zklkhw`=?AN&8~>aD}7irQ}PO?P)ncXxM-fTW~!r*wBqcZW1c$|gkEba#W4v~)|! zxA1+Q^S$T%i*Q}So@=gqjyc9}1l;X!!2j(Ed>rh0?B0Mbc)l%w3H-a(_3$&{h2Z>4 zdBD>Z0lD9w(vRS~W~eooyw*XGeS@5bht#@Zcw_m>1^qcTDYcLwsl>jQXe8 z&6{iMmH(xq0(hwe5eNOsGG|~`DIW{Yw=+}RC?|5#%GV15=vbA#LMgQ^r%t&jo7aZNsd_jj7Gd< zluK|`Si*;_^zpFD!7OUH-&jRbWPeb=tG!FK7hpk?3j&vP!?G%fCDYQ@&l4%3$Kha6 z$6SU)kzzts40v@^`$*zVC|T)KoYYAi&^i^yNAJF5D(%SjX!~mwwy|-P&FI9q5i&)W z`miF(0wT>CO0 zYJY2c$;CMA;5vq8~sEpcWQ>jC36q{v6us=PIl> z5&s&&8O(~%YJ7np)UhbI1rs6ujey&)&8?Kk_?9;?da4B??qI{Mxk}fQuVi24^)X-` z6HYdK-7xB}&Q**TrwzX+oIu69liHowi_W?VC6R+>jOf0T3c^uYx9j!eII6z1X@~;8 z$F;ye*KDtV1t5Sx%i(-ImJhD|vR_=z<@nq7$B%69*JNczDAbd+r(ip$|LY>H3lEM) z+m=TSoG3Yw-?5-6zxZK+>i5Er@mXLYECPx8A#N6Ug||u(N$5dU`jqTjD3K5QQe>25 zOhD^Lc4viGNi8t)@M}F^X!gHBE=N{OWOmE4)T^5SkvCH1F>D)e^(li;scr=g$XtGp z`q7#Uo=I25z$P14fCv$=EL35G6&XesX8NEYl#;sL1JZIW61@rVh_J@{L5LC=#>OA4 zanrP2C7>jjL2^)+=vyr|n$1qOZ@r|X>zRJRl9k+AP;lc=yrcO>lX}0BmOWwTkh))WIh(ng29uWC~7wQE-o5I=2H7Dq+ zK?`0i2?(#sa4;&;lU_&}Y9+c4NLJ205k$#7Iuu!SML&qU9r*R0B2N*fHe;II8%&tk zH4{4NhV0b^zhvoc*2KC5GUb6qfm|85LCcu^nEu<@`L{Fv6n)cRa(SXt1^7XmgJB%) zBFoBh)^eunKZ0>Nd0{ov`}VyphjRtT!s`uP+&)722cxZrEjIX;AO5Xnj$9~JuV~Fc zr@;M-CIlfLeQr8$@mxxce_Lhdx96KrrQ?0t)oyyu->WDEiaVwcFm)9{whY7~ZIYQGPy#%$=^<$oVvJt*0@f?#0M%Ou4e+$jg zUpkFXo@GxzqB%G4RCd$amAUJ5EF?sU=z6ZSiY3Q?3uohNxBvhs_mQ`LNmPS!>chR- zSVEZ;y0HlQJ%X@Rfu)U(MLE)DIC~TYD^v=RkReHPv@EB{ufYt2GzC^Eq=aeMA%qmX zu5dlj%cc&Gx)%__QrUb5cDu zZap`idED<<6fmZ(!QxxG*#^L`6Gyog%i_!s_RW6?Z0|N!75yHFT3k|9eng<)(e0zolF`uia}n z8IOPC)2c(uy4!YKlO>=bSSr64u-CujHxzNB+$ka=Bvi;1S@yYJYKV`IcM}3U9Nhj7 zpI<%LyH#4ujz$e$!@mHd6(3(bD?9Vm{S&m^^mwD*8b%sxD&GjOfe}Urw~R*$4*~Cj(!hY0UquI5 zwXcdQL3JkLFrdRz7yY_4jgC<$Y1AyLX>I)0TKo+t_C-vKz=HS6g@1r=$7m^HmhAtp z7I1YJL{t zq33g=r`qWBR22vsTqQ3csVQv3Z83<0?_DaZZRYQ6Yi9lXV6#Gf&ixRz| z0w(Q|3p5+9`I9%i{VIB7Prs~ex5U(y3=jU%;R&EIBoC+VdwV$ticR6GSW`Gb76dkW z>*(6Is(*Lq6D{J)Y#p;b!F^nyF(gkHR+l%Omo>ebbNbzTgFY) zHTluNfK+N>VVcB^W9dU(0aRA~fUMdgycOI}zD9~$8Wx=;i3ocF%=CTT{CZmwVth8k zkZ%t?_pt-_gOx%F*dC=aByP|}U|NJx3D zOHjF#Z6C+yeGj-=-S~Ty@NyH0A##4s9?)Phnhq$e$&HPSvt?^8gL!AZ8;^fi&tCqn zdDdoed4H1iCOY+Eq#YXZb-d;68M9qPvQGw#Q;r+pujSvF)ip%|e)b`4(=F)e5JXBV zrw&(~wOIailLO1(XiL@8AX0@rRk7H_HC}(I3==`Luw+0>xA;e4=wO3a{!*P3T>C-Z zspHBvw=Jvbb#x%(O7xhea}6Xj@@z8i1rb8N2wMNcHnCcO1Rr@(7C#G^yHVUG1>P^I zj(eWUo9{n38&OvG(e-KfMUc9Pd9oRVI*0)K@4uav4yFgWGP+X4pO2`{9*R}LHm&N7 zQMjJDLssht-bu#qR8~pOBUfO~S}Az1rChSu*idZ^hr%|o57+}3C__OO(&iN+YGe7v z!y@>UbQrrN*M(6`GA22Oh!yiDQJR?2)YGel^6)4$rEV+HggJ}YJ!j_?BarK|UoJOA zF{bnfW|nwGyzP@x98k+4A|?{v$p~Xtl+0r;P_J8RWN&|xf`CDv{mtE-r@Lpt(eL>F zNA6nYu%R;Ae#GMMGU0r+660g{x_Fto8;PPZ77PbMbYa}~i|&sPK7eQ{y10yYl)$aq zt;T>Ijf)rVusUE#O`VYdbzVtvT{`WFJc7T3BjjC&N_q7pl<@`?Z$6Hg*Ztu-92h$@ zTk6}qbHy=%yD77~)3sMhwh--WM6kn~`|@HHFo%3GU#vA8s&iWZMx=$f3=9^d>|JgL zo86u3oyE@YUEhvA-Ct!eW_1~NU)yK^hA9B&yN3Z=Z*YtZxe_6G5y5b^wDj_MEC$ed zgm(wi)X_4<-)j7x{vffAzgyynXAQhi*|ek&lfYrYA}cC|w=bL;Xlw}Z+RWf12F)bL zKLvy0ItB?})p=Tu!ZT*miVoZFMO(|s_ND0BPNeGF@euolQY_W}$CT&1=?Qs$QDl+Z zo)!6mKv)6qTN(MY!+fs{vU<=*$Ue#+s8tuxXwN(8fc2ynv<@6!sdXoVTxXOE3IRn# zFB(KoL^xgneemudQpg+BKcSL=6hnM4(=yiztCX89>>>}Fv*P{-%LU@!cab)oAG=d? z;OEBE5UG!Dw&Y4AoWKSvUu&ESzY8y1SWfo;BWV5$#|-`SyapQ%puf|3{Ux-zLHT`# zcvn0rJg5eCs2%%dzU<#HJ!Sey?ex>P`XQu+$>L2p%|t==84#k*%t= zvI#$wupz%#Z#hkar*+_9l)bW*XwsAou+Zo}$*4#L*ctdcUuGH`hKu9Yv!Eon8zyfl zc%fJ(2d3vM`YI6DyHX(L=S$f!kW7ZSGghZ*#G^`Y9@fI*_|VCPxm9_a1oljvD7R70 z<6=>*j^H4PP+^6=|>yo{q;84K#c&VX@jBHqj2@{c_Y9ux$ zHc1zUY`$$Zh+3`kfsPWpPi{mpOQuE+0PzKe#Q6FL0fO*H)}2q9qkWb#CqbeSv(* zSrB{Zp_Z`QzliUg{SoabYz?I{wU0s7Pwwm|1_6^{r-!r?Z7yshZy|TkqNp->^`t=d zD_G`4c9!t@Wa~VE!Z6q3vuzt)A?q>TMH@y6jSe5Q|4r*-#CaD{Fg!Z}`!K~%i1E3QZ;OBaw z!?ADYM>Plveu!y*WXo-n{{m=mFQ?*Ne-HrLO@F}$O8~+!O6aw}zY!PqoGz{3faFRT zFtE@MPkzd4@%lg@TLqnH?uaGdd)<8Xu^F=R8iA1x)=XQt?+~Mk2ISn+Yq36~)LE-1 ztH0vR4I1qHh7ZCk91lz;K^L%EPzmb0W-8tl^z9sp=+Ghwe)wAQxdYjdr3Yj8(#Zzc zHgffw&u_@imDAuPQ-)Y|MP20-Z>oi;i9XkNXByN1w`k>(akto?rN%` zKOu++;#l=u5W6V8i-`Z!fQ5-fA!SGC<7swf^0$hCES#_EGV!Z|@%{7j^NB+_(7C(3 zOk2<_OGTTOmR4GtBmq&i_pX8pL1Pn#@ml+JX}uY;6)yIR@VAAdzP5|v@B!zS^2to_ z(|`p~57lwMr@uF@&*3xnVUR%JHOa791OyUAUV5&8uD1_e zNOaP^h(UNDAy~leJ26B1HUsEm0z9w)p;67ykPI0y?oDfnlJgw}XTD-SbU1fQO_(yO|xM_UU+ z4`ajs)P7Wx&1{xyG&ifC*Su{!O$@KABijm?TwTZOrdg(d?_n?pO|B!y4!mCe0#@h0 z=kXYeI&)p_+gVBf^a*=C`?bZz6q|o=_#dcV!Ig)B zbz@Vzwwb%ByGVroij|+~jL2pUon*~y%mdpkJIn*|r~&%$TsnQ2!+V;co6?Ce&hEVk znzjpcpMbxA8{Yv$pAZ&SrCLxe2g90cy0S5V26a1}^#{ zAP#@yY5B%8pHPJoE_E>HYIqC_dc-knB8@NofDa>6dj!yi%mx!2Ry!SfA8zV8E|orh z{P^zO$>*#ltBG6yO9T*l3Hgna<93SBOt1M!&!N2Qs3`TBzBRGAzFu(xE_ttaP#Fus z9rM+_z4~u9z1bYmf}9P5F}S@36Zu(4&I*y^PeA*odBUr=pn~W1f|msLz#Z<5&cL;J zt6=c@f`lyMIbNi{tcfrtLGp9PKVX?BzIbCyWY<>AI)?D35*Z-pm&VY^g9XOSMbimfD?qm%^oqSAiAp^#iqJ zR4kL}*}q#-%9LFEkKzfVaCuTjn#yzY<5q9ILZaG+hRH20y`b z!{gEvsP_)Rpj}b86;mLF4!xgt$9FD_tC|Wm9R_7{aPu&sU7{+SMgh#`xYufkcvi+} zz%)BsuJT3zXr}x7tl&_l&X|}^tI}9yeOq!XhrfknurBGvWVT5)x@DuGg||{6_1V(} zE1%v-bcBS?dKZaSz`JV4yGlgUBog$Azmk;06dpS|<*S3R=b4!Extf)g4qXZ(Bcq+I zBl+SCcC?InNyn^D6&_I=&;e0rSfR>R0kO+gU=mnv^beM>(e>^4`Z!T+7DdD+9eX-)iPO?4@|P?m(6Cg^bKnr$usKME|P=l+>^~88XaH+2Vm({3?Hw z)Us@@^R)n9!QNTF{L|I?F-JgR+aZeNF1S|{i$hEJ!*JoP=R4`thjOD>q3M`isy2X(9M13@QcOrNZ`2cMoqw!e4ex{O=w6 zLeAOGk41}v%65n_OBFAFHo7!FzUS}rbtf1*MuSotCmkcyJ=)(Zu|495HD;dpPtOfXme~ebYRFxtF9_l zz5lJ&CpW=Hdq#|n8dM`YVP#B~?K*qCFT@>(?o|I#d3v~p(1cPriqRrbY7}UjW zH8sq(E8gEgbrqaL_m;s!Mss|^Ys)9*_mWWt`V-5q&|wugF5l@=gdy?WmciD-*P`ls zFpySN*X4;hjG*ze{Me2cKJDDk_g%jb1}@wleLze@8{W_cKVJRdQ#_! zTa0`IaIKnLF6W!wXe0~N2wfNV`*+97{+Bz^P5TpaU#35>;3o{GL|OcIcOcwoAe(riBN6W+J%xmDLJeaGS1jv{A!N+We6ebapZ9hAprF zVBVAb`?&}b~@?~FVRH7)@QK{hbs9vA^d+>8qA7Q!4=88GROYqr>LB4JpaB{OUmg)_2 z$_5tq{o-MJfr-DYDBtg}h>Jy09BLfnj)>suSSWT5G2OdxnO@Hsi!-bV2B|6|`m@pj z)zCHsJ82!-+$!ZmVH~s00>Vcab{gefl0l-cm&Jb7SJ25Kh}uw571)qk(AKm(A}gV*p>YdI)w{_7*c7F*O}do*ss{yTl#EhU zRW-?Ae4dC+571tUKT*)+b`b)28rcjxtS@@tQ1&Nt#GTkKADA86EY^F%zYOwW>jU5J zb@%i8i2NWT_OqkW0#p#-zo<0kjm#wdpgv*XyPsmwjN zhWBt&*Vl_A#|==o#Z3b1sFeanB-wz&ryiPJf#L-^0!RD$MB(7m>oSGDNWCno4$NRu~V=cKVQz1oJQp^W3iIwGD1!zI&O+1SEiQV1U7V23FYb0~sQ<%89Q9FYQV?OlH z+TmKN8_!sIn?{LA_r`tAnlOKOd`v2Ga&QoL-aMZ${BT#>E1z(Cdm|VSXz2g^=VCjk zswxIJ!d()mo&QRz#y8)2XA*?sV}Xln{2A24OQSof6lJo#f~e}14>8flMTZ|5rMhX> zGLxH+b0Flk)byA3H#B@VUa_*`vaP?zp4nwRJfrKn$J_iFBfJN|dOhB~0gz$6PH7_d zJ1MVY2Y_wL58Q&IIX3CNBAmHN5WPL-o;>~8S-m}`J9x2}U@I{ZF#U@*mViBoD0)8_Q#Yh6b z&IT`bKk;%uwNKvRx|Q8x2W@H43*msLDni7*T}ofdO;Wq4*VE`|B;sh|ex;9wY3)T2 zSTc#HRz2qQWuBq)Jnu_fO=7=$K@!$_xp$k7Tq`c^sq{e;&7NXTgG!egLzW4($_zo0 z2w=#0lH*ECGPWq5!Bn+H{F?pId;7RCLg?d^DzxrM>e29->b_6X7PL1l+@VZHDjAYL z$W0Q9kQc#pq&Ya z!!lUtI97@8r+P>}VW`axJlSDQacSc&X1cbX{y|@3eU$P*?9HJ5s>;byv(;KeoT}*6 z^&Y`T5ugRRt!aO$k>oWBk;CtM`x%JWY!#d8)YI?$7Cm;jw_^5UC@8yrAjE826h1Xu zCP&T+|C;nwgZ{)M*PPRrDi_LtYWd4CzNp?9*y542U}GP_Pe3?8UI_3$nrlk;e$L{~ z*l?ST1Jc?0^@zidzkWsi#9n$4_H= zha*Pe2yrd)AQSkyP^j5H;D7p`Ab7A{1k_QsZnQ2?-$ZO&*jap9w{b_H>UYQ=_}tkg z^EUt(BNP}uq1BACY%!UF^wCMqCc>*5kOD8-3aCvI9K7+yH~AS*_uB=$ zy_8uWO{4~qMKl`|B3Kbu)58K{Vox2Yo-|Fp$sx6)Y~&ARj44=NA3ncViaCBiAFOB= zyZ0`JC10=IS?O?Wuej{DgbSoGNuByr^lp=xsi3#Wg)H&gL;ECipSKeyzYwgdr}diE ztUgp#+vf?QS%4~Gl3xO$P)rBkFx0U)+1Ob-s>!aUx-`5CyWhL2$K=BKB`?`K{A1Bp&PJKP2QrAOC1isQ|# zXX-6TQ7-~InGnbyQw~avG+r73-Ii3_HFRRjIDkTM{>LNw@%nd+YTx@^)|`3>@SFjZ z4pHB`0zp^4?U>7vs^Jv+NdVQjcWnb8$mIeS8~yX^cRXn2R>(n4(nP$ai<3cqa40+( zRw1UQbjiS5_(RRChCNnqEESdU5cF(eq4H}5gg)p}nX4&GSY3#ErdBN?BdJA<3LFy? zOOlh5&Su#DX})S6n7wbnX|WkBK=hL7@?y-xN%h+g06$4B4_wx?05d-hy}mGDvCN2} zws`k$oZE83;8QP}XN^(Q-UKf{+ebc!->J#JOx@Z0m0(@|uJ?yYL1hvaUdgt<=SC!F z>MMcJJgfE5EFxOGuDFgH)FFaGKfF&Li_Z*R+Q6r`@4n|FsN{<({&6->*O zO&*_1(OU+kK`2a-wDnfQ^(sh0%D;h|%TS_fEx>+a67}Z_eK;`er(Q?`KRy0vdI~!S z5A?46f$;vc7;zGXv6KM@h1WUxb%_H#g*7d{(3XC{*Ng>VlGGZMvj6sX`R_#nYrNsz z$2H@BnlB{1cR+fAJO@+X<B7jH5wRdw86z1{%-q5flVGv~7r8%;CIlyWEqa-yMP7j%2}MsnzlM>WEL|<>x~jq&?TD zyjtzM!*$uoSkJIN+&{zv*e zz3SpqlWi0(F}u-~eLhEn78h!s5&7Az|7rnpetT^z~>Lz|m#fIAU`Mz`Ug-c32#gEC4=iLg)-xQw-hctb-L=0RDFL+&Fop`b@>XnvG? zLoIxnMr|^{yDXWD7yC69sR{KQA!O+{@POp;`tAWNo33ZzOxbb7Rr257lyP_tS8XTbq( zvlOT!cQE((KRd&JS7HYmY9=L+778Bil@PPyE)m{2#!EbtWJ9QQu}-t2*-2Ik@z8kZ zys}g*xJ~U!FJs9oKfoUbUU$^S;zcr|XeJc!FBHe;eG37~}ez1~SHSXgEC$E>fpu&8lH``{OsPcIoJFoN2BKVX>-kDqTQf zT+r{ouw)X?6rqoYER0Apamq={fZyk#U1W!t26dxz63#`OU_>xgSLbBs041<|t_w9b zvz9#k3Ds4#CZm#K-=F6WVGu9?Qj62z#F{WLk9L|Z0@;<8v!ynN$9M9g)&bKD};1h!Gc!yO_`?XI0RS%ooa*N)l*6@LNvhN(O2LM#? z_wez0)7{k87a*k>JE#$C+*;ci(9;*qZDo4HU9OTR0ssq=XVeETkdj##XTCw3om;OS z0AweBsBaUK5)gI$Z0BJ@Dx53)KJeF~p|T~#y`lGbjEqEp|A8eGpDPgKb}xmRI{IT*^1rj>Ax9-aN}P>v)-1Wk*v@CJ=GN%5QfYS7D7O5oEU z9kGWNj3@U2MVMpij}VMa4fHm0khC;Vnp7iUuL#_Yp*Z`cN1T{Zh}|GS5r$xls2!pj8m1ZyI{i#ML#yDWd$4t3Gdh}> z(EuYxf6x*FCU6ce5P2>r_`9zP^*BKat8(YQsf}FGS(@CU1HQ~=_xU1y!eN|GtW4mb zC|BzU|0;L=J7NLK?+;F6Lnjozqt~L>|FeoEWFo>uV16ajZuAlAFv5(bA~5Q+hNY!v zmDeP3!-bT@UO*N!RISPOvZI^^4$eme@w%F79&W?6P|DL)h;bY%Zqx9DWub%*%p!;A z(i8{|6#S#fF}zLe(5MkaZfFziRsXP{-`D)A*Ln|xzAxiQa56;&KW%r!d{1BOboD+V zyJ8|xiQ-v=rJ@8AP1X+MSr2}~tU-`Q3PHS94U-=niI9>!q*MYkxfQ~ks1uhSXQP;X zV|~UQ|PNBGkZQ2EPnVcLF0yYEZBZro?Pe%@KGuR3Q%`DB#T)n-+9dw@? zygWSt5sA#Sk_*@4L4n-GGM zK`E21XK9Lv>#~jz8hs^^L}A*u3X5e%O`Y9jieqN0OM$zo+4U za^cQo9LDMYKi;i64C;s}t30dhe{GY%bCVog?Vvt1?W<%!o7GV=o8_jWUdfZl+?~Kc zls_!-g;xlw7F?QhU-dLMC4xqsM2#a5Lu%7YNF02P=ht2-F%$X3ml1I?o%RHyliBy~` zgO`^V=+)4mQ_ThtJ3z8^Zz6}=Zea}2D&J)bRF+y8jJCS4DrM*7?6wi@YI0CM?MfVZ=28Un9C1ZzSnIHaF))UeOlu#Exx zR!Pb3=y!H@&T9>x`$#Gf$WY1Qa-S(t+#aAb5wB`*=ORTH28VhrQcr5EaU>m!S7*Ja z^M?HEmJDXjhVnyi!`+mQHHt{=vSMg2HmoD{gFA@6L8pOFuB6#+LWi)3U{I(cSU#pZ zxI{vigg?Rn1RA@fz+Xc*B7VlIrE8k)0v|Mq*(#a7=5m_Ab2zPp^fx(y@W=Jqt@o1K zH1-nSaAQ@l)G?x`K|7A5K@bAq1oi2lcdTUZw-hF&eKuiJ3rZ!`IXEdZmoIfeAzJ*W z>7jKXc@;8`a0CitfvBtxw}t3DB5$fJVf#gvoWpFlxlj!P;FS~pvrPhnqbqQHVa*1@ty%)T;XK0j}A|F`6}S*5*q?7AXf-1<8jwcb{( zyJ(DmcdYVXeCK}0^0m!Oa=x*$bao&@F6}V}CVhM;ta5NQ6Tys1fXDc)IZ8zutviE0 zqgF@e%-14z+%Tq1spiGzkC#2_QJK-r_po0?JoCKtB!_1 zom#cdMuX+JEh5i^=Xx7mEbq*RCc0OkpD1ay4z<|gc|7ld{I6Ty%d>dtUKsz!Y+>Ua z5J{qm=TL+$3}29f@}7tCVZ5;8n~+t?YR8Yfu+78M zi}*X5yX8ohn?(|HMk0G9;>KoCLM$mu=uE*hQCkfnCFkM0NN?-keO}F+lL2EoH!9Dg z{8S?9?!(vh;_5vJ=HTmw=-Ls2>@6$J$;{Z0&p{>rx~!|CN+U69=w4xop6>~=gN3Qx(^y?dC$+=drTZkwx&l70;xQj33lp>QraQDeaea?rv7Z-OY2T%hjT z)4sa39#ad;c7W4LPlZ^{IMJJW9qC}q&vgSR(ufq&!r$64+sZG->-x_cLeAC#&JY6j zdm)c!e?iV?Cuh21cb3LI2gCCg%@TA;6I6L4B^rce{6rFHrHl<u{4B=K8L1s z5(`)&J6De&7V*5~F65`!eR5bM!ShdY<{(QjD@pwlw7Etr$aKl4=8BhrqD-nD8!%F~ z+7fZ#{7*gHtNI#+`hnW#UvA<*O2R*RmdRrC$!43fVBfozx#?KIB1yEHH5erDQyE^b zcih}zalCNh#=zPw)XEvrS<6YUH}G%afU5;J1pwQ6{@g5KRS<=q)8)s&T#%W}gULE% zi7ce8xKzvJLOk-Uu%$|jmphG{FV5CsEp?F`-T#&jk3G+z1J_6C0xc!RDF{uO(WuI~ zH+2Y2LkV9sdaQsjYZg0SNJylUa?XH+&~Ueo`A!R0xF2futk=wtZUmYH| zhRKPC+;MNZ9s#upb&YAR6M6&ERXX<)Ih(jk;f+8~bvGKMKZgneRq7fdeBGkD=OJ61 ze1nLKhnJ?4x;Q@%@LZ;5W_WC*Mt2mq%T&n4EoQ$FfC?_Zz9~J2*x!e17;vGu!eDXVNd^k^8`+F9|j0{mq*DHm|eU6e$7o>o# zA_OAkPb~`4o9myqQp>JjvG(sjznZ`Ps}vpOKsEXN=V;vuel-W!9r|F01dxKhA{ zj}Uz%7!sjqV|UZ|Fq4=SDv6N`deR$3pF*|>t&W7+L2YV1&()>P{Wc=kfNMI(krb9l zM3?0%wa1u3IhnSe*o!B7yQK6o7qOXJHWiXS_3L7b)?1lDPlU3=x})#ch3s2gV-{=1 z>~&20T2xJf)lw+*CG+aI$gp(!p||vUX*ksu5eJQc7M_5P4yHhuoK{j`pe!y4aYN-=EOtY+>4K+es_x7&9trD|9NV zD-$ce*3?y2)qSn04t}qzqc4bDQB7JprKXc7>py;wG*y`Q#a2F{%BG^O_A3)RGb?%| zG}r=*@9R_O{!%MPGz379o+iblhDK+7HgJT8ojE|JX&XG%NUXD=Jo^v|k9D9U@yS4{ zxo52mvzDGKpC8q^9r_MsnX!+EI=(^+71yRaY>3LOq0;iJRlsZ9}}K0rgs z_nx)*iy;j*unwB8V%vMuY$1EVS%^rK7)F1Nv_V9zlBl?e0!fC(j%E7nV7#ShhCzn9GuQY>CpW6l>IpQVHs&pv3H05lOPgLr=Oh;D2bQq!M_T- z7&a}vFTj2O)dCDa(~S+7?KCSS%2pwbi}9BMDaiH97QAqZZ<&*0uKWH zPkRIJ2+m$Udb0i@+bpwb8MDbo7<0@=V99HL5^ed3;P-1i;blA$h%R2-1rWF10jWhE zWmvzRQPh$(8R9c1Wcy|O#EFubVc0}PbI=x&04)ocWngWyeH>0c!TuCH}hl4X^;pOdrb_&Hp(H{YP5j zfj_sNRELI1m@SFMp6p1NDU8{ z1WN&w)f(0#$0W8uM1jI|1UW`G)_N*^GU$Saa5Q-`fKw~zvX}%zZl8IAe+pe- zBhb(n@)4cMNdnqH3FGK3Bs-7Ar4fDdYImc**{FTvpJbupEWTk6+b z=b{!;^WN_ET+b~w9~-i4{M>0$Or z^{+>-ml#UE5Ks?I!S3Kk4&#!Mbov_H7@VXf+PhIjsd% z>ugwMhkU+f?}t9l6uC5h&^#^guI#Ra?t4KsqDoQ?Zum?dk6+*RDj|KGIKk~z55;P* z^-I_ah1=t3XCH|1lVqKJ)Wgnc6o&ZyJBERF_$}!SSTo6Z2SJa(K-`1hMhFbteZ+HCCpySe@6)B?b!#=LMDaP+_qyM?Ug4LtT)Tc1j_ zbs21iN(j++`-mjWbhmbVr6--1AI2pB1N^gb+64``UezlSH!HIVJ(qSzvV?~l=Pji+ zMiyKfdF5Gj@{!W&P1yc>bSZz|{1S4v3lyP2EX|ClY+Ly$XY@)Jvts4rU{v(4}WH>h~!IM`bFIFMsr>e7$CU6|kJsxFDB~ z0S#>^wD*Ww$yIe-7z$-6pFNe{cD~uvz+9KxiKKKWQUe`Ib1o)HkhZ@+6Z3+(e1uLa zMpV<#hDi;3J6~{*XzD{8MF!QLVh@Lmdkz;gqji_h-L@MYS*(2U=ZtAJN`;4`ikgXx2JMrOe+97y<*1n!|5K%T zl>BP%VPpTU`|W=X48O7%EnZnNdG(DcgA`4ukSysewc)5{BIeE+>tzK}+=gw_%4njD(<$%;z4{a^ zd}Of4$cLG%6LjUVg+_w2-tXjo@QN81I(wV$POc_fVIc!GX3Zq2zWbIV3W*AFGN^P| zZ!;NvcR~y+g7wF5V>~6@uPeW7+UzCeBDf0cXlBqT7NDIz$4ap@xAWk0+sC@Bng}3T zAer9lbi?Te(X${WuSyeNCWQ5-2Xg$Ru9O8MPf!NE{5roxYx1w!Tftw-YWy%2nu{6( z#HGOk+=@fXO}v_Btk|fMsCrQ3__FOtJe;^V+N0{4NvYZa5p@6OS;yX4tlko%T^LZOMXQTq)m+ak^k(kekn0!3Afqsfd#HB^gbswQ z&_n|6zvsGP5b7Y&S&W83P}~2*V$#8ZS(xY1<<|LMR|T*8{%9YFSyE_x;g+)CZEYJ&Z_qJ;a4@{ zW{$$9A-+UfZ9Yv(7P_Yz^#j3;vZ9cnB6JNOSE3=DS~P<}$;6*@sMGM@8l$t8k`ps@ z=tr-z;cPyX*cOHJP045#k;K`T{OyN2FmPB<{men>=0q_4$(rgd2X%f4IJ0zI9oa#b zO$1THlqgyoZX5}keQT<)Mo}U z-O-Pa-zNjN9yffAPgdkNLL@hiWq;Vdgg?oDY0!xa+=kk`HSD@Ny?g17?0iTI4A}mW zMPADWKW6_=A%y5dK;%&!a9=Vh|DPA)>mLw>>s(t4k!C7VD*p4%7uew^syzBO7HaiE zY$jHA>a7f8)AYea41Q8(@f<WX{_Ly+JS!@y;SYcJ@KOX>KE<`p4{7Mx{b+RJNT`}@55s!%Epw%v3A-jqgwy{}P3 z8RZ#;)ysk-1)k_X{(#`>7^osK<(+aCXVpo9sODio=|J0%6vg%lxk>U<0aO`Z-ylt{ zq5#1KOUk*s@o6FpxH8yvem9LuBL5*zY<$M8(vC*w`>!ZE(4&^MA4I%~jFCtv61aY2 zOam8QUeFFxji6k-=RtG((S;%OIJ`;|7|Fm22E;s~ArSig=-a9w600@0yP5A!IiA(y zsEPDkHUtu`W0*lu3es%BEVTJYbibY<4czj#&8MP%4~c}jkh-d&JS`|PG*&yO;DIP7 z0)QuV^5xC_Qnl#`b;1utZkcuB=pC(=wO~Ptq^G8WzfaT63D2947*EgpT~WuE@-KhP z0DRgD9hyI_o9{Vpgp*s30 zA(Axg#axIzn7Jh>h(>gKHImBVd>4gpDqVDGlJ$U=D9&Phril6@+)ok5Xj;UnaY-u4 z#i*j~b2948^cLZ|1ZfdynD)grP$Y#?l7b0RUH@h5Z|a#!e6A<-e;m|@ecP=ojoR0Z^;E>Iy}0i}U@cOFC)ar3gPK07u9ZjQRFlq!Bsi%PYp3kHMp* z=)+)x4U6P+z>EV>o_$E@BfecA{N0pRprOta`Nab?<~%GvzK|Y1VcEr9`N2bWHvIph z5F4L81u2vWoUyRQ?>M}cx}(Dbm~r4E;2i>ve?drB1yzhPb;5NL%+Ayf>VG=FhFo1icx{f7uY8kt(GiRjhCX? z?^PmLnfXFopNJ9Eq!)ojt->_Z1C0^Q>!#DFGN2F$Zm5mGf4Jb+L*{LtI|oYDLMqJ) zO{iy#31N~As1o1lqQE6XFM4l6Q|^g)RWOJ%el4D zxY}h~7~PPq+ipg0f~efgn3G zi3?lsUuMatr*V+hvVv(=us+A*!P4NXD`KH_K-~*6(Aa_H3rtmCcS0{!S@ud}%t@Ml zk@6(JP$3l*F99&pOk~aUKcwJ)%VqW;SgNdErJuvyhWaA>XtMB^KC;WiQz9pHhQ&gW zJn7OryWdmrnc#+o2nS%e&~lj>E6GMj`{btn*waP31!0!ZB`2LkIwPeV=c_o?M}Lmd z9R39=S1jpxOPoV2JjP$B>NgDfQ_51m3aJ<)-z#?as^5Xa1K#Y4F#n zuQA_Xk+e}`Ek#fZ`43}4AXnmhpN1n{hKMm9WHzNrSV)kT{4`EKEhB<=;~_-%)8~P= z@f~2vAW*=8VkhJ`cecr0(=Mu++V6u_$2RhXHfTTeX$B;5e==^mbW1lexvcdT7mi2c z=IN_tH9{1PoXn7Eu7&lR;W*h=!88BO)Mq1n6S5%`Vtn|vEEJ9BSeU;>xoFvZ+{Fi{ z$+tR-PRoE)RP4 zcMKazl)XPcJ(TFSAbh;pU|}suItiZ?$a@syIm|BHyOy8Dv-yS=6ULQ?E;VL=W3!DG zQ^xM6ePbIP(c`L6Be^RhplrUVO}58k7Nk5y=Y_D_w1kK%;Q_c07mO7@q+9WwRHZz^dL zridhv?+NDF(3ddqSd;d_UZacnI}*O-Yr>ZdCsOdft9k8DTsBrN^U^By`FXreZI8q< zYD5&wUov%WZ_od70kMj~g?RIBwVfY%EWae8aik}@@JCw0O*qv|(1)A6>Cop;)pD`C zaQe}Sh<*>bqZ$H@L;i@)(?rLNJy*G$x7iDTAI6^LA!(4R&;ObT0dpQ>_rQ#Q`b!ZI zSlMpS{QoHeUb3M_Ka6WUAl#Rr5%8WCZIktfrVQUES4Mb$PXhym(S4TmJ!PJh#5W4n zoxIRVG|h4AT7*)Q-CUCX8@YjN0<$l5Uc8o;RT!bcpYdeWm+|S)#gcVtigBg%>yTJ+ zcg}Hybiykzpf=r*6Zu^4NbQId=^Xa{ zV5_C)O>uH{FiDr7sy4M8HME=>YUI16 z5k2#iiBOB>m=>32ktihnPBfRLb3wVpBuet)iAm1Y;D^=loW=D7$2g^z4Za5`%71q+ zVf@%pG}qsjhU+x#B625mbiH`lXhsJ#`hDF`EqrNWp7e5_&>eCrkB^V9>L+ba0OXQQ z4X^Qv?D$&%>;S|C9^)Z@Usv{wb2O4vJ4l7tNY`7abAW?JvKD&h_EYFN~RB+K3{oyIg( zh-X=hHrEQk$%@7TTP|7?sYsu8NnJU)rii* zMFYPX(?3j-DhBvI0(+(9L-RmU<*^3j9y<930-&*uK3#|M@|MUsZpStENKD+@Kdtiz zx)T{ZV*mUOqu)UzYRA%v;&THJUn3dy(Rz^hlsDe$Jx$*B22uWnPa99073Q$UT>QK- zL_}%bm9HiOe;)vun&R8IMYKONyozJgNk4Q>B^soW5#$j51jtGargaWmi>95{QskT> z1J2zCpUSTD^a2sV8<*7n+v%praSf+%A}#Su@+a8&GaR2mN($e@z7XY3))a*H_<9?q>-S+?dtgM{>)AM-=J~r#} zyyJS+^m#;5vgMXHHs|^8KK1j^;M$F7X9R%&+-GxOP&M%485xFT()KSs2+WI65b5}I zmjk6E`)HVJj$QhEKn*$pJ5CacAefmoRR%ufpc{3^Z`qtf-Q2${sUjCgROqfYv^aZI ztwWKiMLLBrVuJ%8r*n3n0B+8KcJ zdi#2M7)8|M=x@#{bYJp*4ZeySp|GpbkrdMI|4OcybcZFtSKnwRe>oIc@xG%q4h-a9 zma*^l%&(FTe)Z~9aU*7Qy?|1B&+xsL#%MKo-b%t3Ylg{q{}X>1cgdl3&<3`|7lXsC z2ePr;hlhE%K%RzOvF!2lZts@@u{0Q#Ul9l!9>aM`tc+ZwC3$ahYSCSG0lpXUB8d4p&!qgJ-#;$9{b0Za&Aqb>(c$9lwEEe zU?Zy!E1u=B7`F`%j-y7ohm$tNsoD`bVA{M#dZguI6~J&d{MMzms@S#lj2fqqn>qFLB5X3k%8I?}N(A{*X1N(Os2K>w5VEyA}-CU>K_%DQAx!{U`h6E43 zHnby+-DAndN&&0zR7-THhr_o|30Z#?A@>%gjz-TA$$Oln%C29_XZJ5fJ)Pasy!g6o zbJpFD}!3G4A@SjDCiD-^!Z9sHV6CfL+H6APNW>q%@1XRB7_!lrP61wFluU^Tk_oGpvEJHPgW9S*p7gK+$$Wc&x*5ga6KJ7uD3(E$2_Al zGRAuo0S%QnX;!#jjXoLG%+U*NG6j7f^lEcIDB_GTk?{V&oiNr1Pm7E~gbZ{fEw#Bc zb&U;7`F+*iPp_a}2k$+)NZi6=&K58UXxKX1T1LZ&Bmd02`gu!>1RR7WN2c%Uj5?8L z5Jxkb)FEAZ(wzE=VjYR?Xyupq3rpdT5z~w=6Rep}zhhrj!Y;4@_E8^3!A2&FQHO_t z32X|kkAGTq=`~{HL@@}=kC$HFWgV{?J!$|j7DUG%d+%TRW#kLJ_I!Sh|7kq*G-Bwo z0Idm!*_JR;ZsMpDx*6frV%K~|bm%#TQyv6t=fm-E#ns1)wFunZ`~{>XO<@ku*n1Ep zE{CBH+n^^cx!?G`&nL`_ol%vbX7irD?)6VWvAQsZ@BM{m0&n?2u$XeL*NW^f2LW)< z=Kckv7I?aZ3u%$~$xfTY5aIG@DLs3H5{w9 zLDi3-vmTKPy$4!ydmG-*cX0hVcA64g9A{0|Lm zNVn&XabhK~F0gBVW-M-VmL#hc=RDch3C%AE~E-Nw~H!3f)1 za${W=g#w_bBJ{k?4Kw$&Z9we&_wM9;MF7V+Xmub>uVD{_Yu@)jPi>m>1dkLi!FTl~ z!$zXD(k6tlAg>{>v8@L4-^o?upBNcF@2Otu*Q*O#UtSDj1*EO#VN8t^CB{)MoGsB{;LZx#PO?hUpNX6tL>pi7$^=?a43r&8M` z>tFL+@YYtJgY7eeJ32b_+7~zJIq0;Ofg<{5J-IPc;BVtaJ*Pl5qxo=W+W4CSvh?33SO}rg?%B6f;y zsk3rHn!+3Ug3f1suXFpj3_N<@1Dy3!Z71}CSmMSHbW~MfhVLV#bGk5Gz#LC5AVCoW z6L)$I3|;;L%(Q=N8;ubq%fn#6An_{W$XTxZz2sL6Uem`O3DN|iQGd;V{rN>-=Iazr zA6lWDuM*P4G8-VEsb7%vcW4MQyAQc~DMlke;Hj6|wDY0761ugCeBQ?YtxF;5kq$)m z99x#FA7BN(xv2_V^<8xa^yrna?OQd8ALk|bdWib~l{!sRQ@sTQ&&?DjjOTCQ67hBT zeI5cP$o0Dn^R*m}gnj!6QUE-U46fX*d105%_Jg@`Xh;Q!9tqj^5)9mf z2h_i~C2KE-pPA#_2cDAkCctBFE8vl;cB?KEY6qF*(5T6luSE144f4%5qReQZAo64A zrgugl^t|&<)%4zf+33k8-*lyoRWi2G`+$8b$rw5LOG*ldaUDziuugGLvZ z(qR*p=ccV0LtpRvqxoh8AHcQ=A0n6JhcW@Kq@LA>bd;B6}1}q zUb)BzkzQVdEkTwdiE7L`nT1wHFnC8FMNX`YyR4ZhFyM>ii&7a=2^mw>Fb;6@I2!`u z#+{yonVqe>k1#M)CxR@XY|ApLu<0e@4c4-9|ldMP3`ZQ*NYipY7%1!ux@XTgq+|4qbE{ ziV45d#+_{|Sq0Su{3W>?c2mpu3lds%ewZ-FK4@W4j9vM#UMrS@sV^eFD_)Y{GsgHc z5B!&dl18dfA1yqs{Bvw=9q^ks$oQw)0_Z|A+uNJ@#NIK$S{lGu0ASk-guiF;W z^o|){U3dHY%2~!oD`npVaLJlGtSKBCtpGKQF81@9XQc}k?w)wC=wzRmC zGyw}Fz$$&t&gDeqT*v*$ttr^`RCN8u??E|m``Gk(o?1MCo2-{db4aSL(g{cv*wIb! z&Hv>Bp0}h|l+6fqcOCjRunH2ru=w3MUxjEPVjAtUY9Z8Q&*|Q4QQ(&Jyvp{yaZP(@lVh8uL=xbVQk+m%$C;k-^`=KQ$H^Z z-e_0L!T|MS_3L`K&7OWnMka^KVZi9};+BWT)f6p))`rG;fcIX&kG6f0#wv+fj_o^~ z@%${6j4nCQHDdVUjWgA4IFX5f4$(Jw{Vf?wohrQd=Qu;Fp>=jfnm zFzP&vGf{a;4GPS!cmC91;;X!+8FR)p7hBAH@e@2h>V>p!puITLiD5&iCFpld^{Q0 z8rqM4TD`DwB=MLS{~qOmkg)jT`p>-O&O@2#`8f8@6DtG1VHZfE>qQ9d1&TWt@m-|- z0S?c)cFS+G&%wB)!d^^|of3~Uc(?R(WWd6?gU!ZegqKi9-P<{MX=izuPwG z-FmI_TAA0H+rFv4E+id|$I+-7oCo#QMBcesYxi?(^=TgX0&0VOEc~_NAnj$6&gJtb@+KJlzgT`obi_^i1@|gYLV^*?}b=wvNAd zAY|->_=n_pa3>30&Lgz;jwwhh_3jb+l%2`Iz+kRuAg86(mD->C&M>72{T*!e><3u8 zQa#ukUwd4_r%IxPoOVu`iRgEN)pOzU?RAYDA^Go!>OZ141rZsnz`W5<&UAx}`*rX; zsarGATGEfY`Ct8M^2Vd{BaTVo9?TQ?S~bZ; zjhr?(i{g3+fARhopJUVDZk>6+3F?D|Q{*CeDx4KHq3e=ygS1nkAJ2?^*|2Fg(e`pJ zq%tpGsXAlA5KGZc@kzUCDpoC4y9C3Ia_UmLu3#$nUoyYLuoB>^m!+hm#uhblhSHQT8|6V-`bbfv!e!TcIh%% zvUNbh;y99EP!nKKX9R<{MNGlevV$JDSy>2V{K2P2cWU1*;4mi#{4r;u+qd!h!&?HD zvwf=#xsni$M1vvl$mPcEf+Y{=Ib^SWV{SCfX^QGPj-CdZAtLg*fL;o}&F==)uOhFl zZI;Nsv2w8J*W5S%Og{g1Uur5dRA$(`(vX%cttec4KqsSUyu90&K3nHD7zZYume*Hy zCHY*BZ%(A&c92BznM{r;Ce)0>Y3Gam^}`?>3=7ux*>tU45%J(pEUxNBi@#h>~ z=Zu?!gT+R{n2={xs4qY#GUaubNiIB@o+2JD$v(^k-@^g)O)vSGtX zCEjAi$+sRxDQ!P|pVp<8(+&z~>(*T#vYzO1Vx}TM-Xqlo{exV!3-gGFb^mK?*?iNZ}k{Io@V}Gu(dsij)v~+V?lvo zYfa6ALrNeIc&rZ!(EBL6EN*=59@6fssRkC5vQw%G!%Xn}V_191QDBW3Zd>8kjZs^B z8pnPSvKP7zRh2UIrXVP7c;j7+!7q!Ci}A0GvlSMPw9&D%)pwN;i+k_T5V+8fOeSno zFS(?c#uOQ{n=Vb#?b@N#+a20z>L!=1lo_LFWn2zzhPFyVh1?ljdD@jp*skIMtL}hp zYSX}nU7MT$$l}?!SHL6b-`{H?N-hXj#^e28qwa)yd!kkKB-9EZj#{{}Im2PQ0Gw+fX8Tky`QA@(U zPxTsEL{DC7$QZFnOx+YJTja%bxygx{xKPDWT&N?z#Ug@B9bb?|6lUp_c+acwE;26V7^C5&*>OcxSQ zhBCkHeucwi`5D(CV^jQ0n-oR?Qgt=n!9*VY3H4$4`gSjgY)4ft3|Asbe+>g{65YV9 zjESW^4!wX?(U-7I1PemeE30tCW84O-Yo@~xlkVa$F}i9P6w>7|x<{GayZXzqxAeie zs&%gNTjGE5nO9(+8hB>EkMMjjH`z;fi4ZCKYrs#x%2%}g zMPJBR^ikudJtNG4{-0MB3!jzwS ztXSpl!UUf;R&9!ff;VpIu|7WvA^OqIM~6+zXflw611r(kloA--6a5+@2gd=36M#lm ztOk`psg&3JO%yhwxH*NqD)?^gcp=|Q zwdP7g*7A+n(znQDI`8IJTdIXt`92NJPHclXZq8v_KP)^tb>1`(j0+b;TmH(1m|b9( zO@k+>tIT`*RoLKVS}LuV72u6x-1Bo{X;fOEz zYyIi*)t_cTJmE70$g3xTjNHKhQezlY3Vc}Ox^Zs*>x>qj3?NC9p<(GcAiY`{~F#U+bDlp87A=p&z)BZ;mBj#P3={Ej_R@K$x|#!qV$S-hZuzeSg=S?23KsbSxn?|m|wuzO6Bh79}+?E1^a9t zc<-+!PoSSM@p)DtGp)x*D*ZpQpk1x;nbuyHJeG9EAwr@}#+eq5e&GLs?v}M1Hm(eV!f~;2eW7I$-dNct z5l|@19vCmGkW6NbdfTS-MqXPK>#?T_+w%~JZDUUkyj1U;EJ>jo?&+d}_6wXW4r4KT z#eU;=c9g&s+nSDfyq2J%j4S2|F1PLXy*Q;$P5PiLyX~dwq)+^$vpY2-rR`WygK!Z` z|Nioih2#vKz%AYQXshPOjDNulB^>j>xioat6p}Pm7H;LJXV2+oq@%Bo!v0t;WYN)4 zF8L8D?kOW@5CVt0N9i~1-L%T;*Uc6Pu3mtOcb`&;-9HJQC`v*ScT9bJ97fJYAwsuu z0b!UOZH4qv0>1{KBu388M4B}@v=TMCTh6RfJBPZa7;~cKZE#{<=@PRf>*-a5a2vKmMj%Y19R@yKXo^< zs50-iZ0gtBkNt<7_pTBuohVg~d;bP9E7|%yHOIV@;^x9-`pCt#;R?4P2gji4hR_$1 zbZIU}8%lOBJ0wLum+l&`NgsktBS;=4yH$*PHOLSHGw(0E;!q!8?vUTXGy6QJN+gcl z7}(v`j9Sj3!hMhLnYDw#@h&Edhdzzh;@K-5DN-4#kG&S(C(7!=CW)!`SMwNo-_F@cW6%%^H%%!I_o$lhkUv^)g*C=8FG(c4vS&}?x_Js*jSir zhPZsgS@({bb3)R-pBN@@#BM>B%{nuK@ppgSY1Z*^*R@T4qg)EkT0+c-k5Gg6OV$VX z>SQ{n)NkLOrW1Kv1MI-`Obze&SVdKm-{^EWn9NDu(z1P;R-g+-$N2LM4hK<*d=idA z9T|=l4_mJgqYaYJkt$GiE?=SsGHSXvpvDccP} zQ5}CJRxX@Msx~yMj}^pc8ER1p=FHE(gmkPloL^(#6;gkCOg=ZkUgg`ljnvZ!0l+>H zrGzUATNDLr908$x6xO50y`;APg21a>j@AEi0pwq(gk5tloP)LUUITJ%W6yVSfHmy{ zxvs0X>vW*M5L=pb%(1y1E#YU1Q`>Ty@QMT6bl3LhyZ2NWDrpHqFm)FuipZ(LJ{326u4LWcEI z4{TgcwAC_~bgnlesC&L?EKo)*P<}P>@!|Y~`zcUV3CQH1Al!ua@CbM@xPcTbjRsR#lW=%d}0$|AX>8}p1*%5Nxi47a^D=NNlTi2waqMKB4)ls z82UL|r^C;14yBAaSOx#U-LO;@Df?lg4R?O+p3^wWDi(gfql{@T8K{6*f)PMUdo^dc zft{uqQxfq{?|6`q?(dF!^~pp|*pw8cKH5KGQ+)YQ(}xhgKLhHLa<4;zr`wc#^Wcyl zk2U>VA$neQ+9>8We3~}4`j^O>nsunYh~rWcbFLUHTZ(>Ih`du%ITPlCR2|g#>ymyP*?B6rk@Q#! zl~pk+bdFAcyc?kxE^4J{GxD7sSy+!%aMnD?xX=nRv)>+rFKfpb2?@Oe?x`M|fWvXT zytV9g;06HAjYTkENC z2)?%`u&PGZVXUIS1%A^PE)aEpiHd z(?5h4pSLk#0UL_suQ63E6c1Ge%ff#Nj*XqAvzV<|jl4|@a98B!c&e8C^U!-v-SSX; zISs{WRc?RZ>NyeGEjbKh9TH4>s=MrlB2#2@9KV5G%8YX?-pNWSFJlNcr0206 zYHlKMkuJ`5~rF&>>0ze*BYm9Cq%fWacO?Iftt ztccX{1P<4beyR2H3Oszw4*-dMwHg2-B7Ae$62GeeT~n$>DC8bDn_FRSlwm)a|wAN2p8||qAi?Ss)0h1)v9;n-Lm)ah64gKqEDaH zP_8g=fe%rXdY-P(zQ6EHbR{k6E2867@;dv{PjBin>}TD-&4@=nC@%~UI@^vdu%koA zJ|@}FeR<1Kyxuwq+>(d-E%9lSPgg>e&i!1mAh4~iRZfb|HTpgV!^Ay`_sAMb1$)Dj zCQC|YA}TGtP@EsceBg5D%oU_YO3M0K{z$p3d3)`~7!codFFBwIi!(etiOEXV5Jo(j zdE7}hBz^Dtr!lWqU4ofk9Q}%p&2UE{`hEGBrpZh+k$hhA^7pwSojZOJkK?6k(Uet& z!E0lb-T}*=Nz|f+$10U@fvtX>@Q6E5z{R&7*>Iry6qZVhbp?huR}sm~^7lD7+6Twz z8rm}xjEszhO!XcH>WEE3l$2B$-WZy^1;2|59H!PC8Ke(Cv_CZ;!!97Ue(k^2(}ao5 z?lnixzg*<}^?A2I!zwj7SsWVKeCr0!hq1TX?kA+pO80ws*k=R!=Gg$8_gGThviFs` z`%{BF$J6EDp4j!@+FO^i)1`WLi^~Qb0UKA>nqY#^9^FCTkZ5T}!EdhgwkmTDjq4vY zFco@Vf}&N_T^a);&@$w!`}wN==b50L2lB zo}wyETX_DBh)+O7P@nVvc_3#DZZv@wmIYupS9txHHcXuz@x?271J^`Q*;b)9LW+GR zc{R5_+7_eZY0ik6AXSg!ZFcqSnN@MA7}9I1b{Ma+W4#Vd6KnGseoJ=>AWStcdp5H$ z$JN$lavSq503sLJ4(IQeiH4gv(-)d(H|5O*4PJ#%0&Gecqga~%$^G()`e@4=(?`B? z1vnlWtGbn}h0}iF)myO1#ED92Oev7t3*tar4*_cs9Vl}?53Dec$G5L5A=&}DknStAG zgrDWj?vEytPf`Z#((SQ7>oK^r^lu6(T0H?wRjOOjMRwA!Yj3|H=aDMqb6g{-opX_s z952KbIq+l-g{_txp%G965K|)gqyp?HLnsb-~BQJEt(NQ{G%3 zU34X4(SBsQ#855p6|pP*qjc5TUN$G2JD zQY|z-9gj;s?M3Hr(QCdU<1Kw_*gTSs`Br7nFhzmtNwdV6LNOvsQ+S5%$Lkq}f=Izx zTV8Iow{H(+*$I<%nN(6G#L5W@mW}F84P9Mb&8B+{oYH%0_0}Qk>wtuo z5>F3sC>cR&tiIhl0|XbfZmxLH6?5Ry=ef#f91q+V4HzZ6-It`U^*(ePY`L#dC{6Rd zKahqR4fLI1>`#q9q!+ttPZ!1Lgc=ZWWZ48fZ_u1BeHVsC`baZftO8yQlrhSgii7>C zInRsS19j2so=&|yl!Ef(YjEbDUrIZrTn@N<8zX}^WtFA~O-qk*W}~gG+FXq{sIS|` zB^XI{skq@nKW8o9tlYBNfS~O&=`JH?xcmDcP zg#a2N%erp|g#M2@X-SX_Sy%aydrIgVDYep|+TY(6g*&rTS#&wM(#Gl^p$XnFc&W_Rh(`1p?*(uu`Z`#Zcc<>3`?SmC zjylIPIv=$lAL4GRZ&USAThyLU1DfhiisH_{LLk&c&DUqj*TfR<4#!!{EPr+5lK^}! z*s|{%-dd|O?QoioiQrTpK!?3Nw>TUUaPPS!cxY^w9g0170uOa!(_UyC)q@ZUI;fKd ziX9PiWTEE~_x!3!2F*%w@OxD&bFgDz7!fMhwUHTlja?9V^>aQ+sr6{Os1m@ocmm zt|e(xO`krELCy4pW%@Gs`XjNa!^85RFoc;lTOLqw&~_hmUm!5V4>J#zN#TRJoQfjw zlTHNKCxD8wT>#RS(8;w?=4!PDC(gb5vL35Vv#Z>B$_;RZZ!6^vhJV-j(-Cl-{qhZf zuo4NzJf4fCa4DaiU`N`&5yS2%@Sxk&V$@%{*xvPe&IkZfQ-Wc+VYAGYj}Ng*vFp!v zH<1PLgH6O^p)ynEep=beBsEvG&^K&CWqz`JsCFun!d6x?ibSR9LE_(L(br9?-x-{~ z$3NTnA@mFGLjMVAe5tRM>CGi{PT{rP6=#okXpNH!pXiD5TNOx#_R~GG{`PKJw;&=h zNDcp;7>QqXj_lD~yQu%;jZ;VRIn+E+t6*>=wq;9}w+0ulEB>utVod9y!jrVPkpaD$ zFbj}?8BuME>OR-KzF?awRF=4RJt z&)`PseVvRoOu5*ud{lI9yjKADO41}8i$WrZbRt*mmA%lBJe^jFgF5*^PX+PX!?a(X z&a$Vn+qW<}J5$GX0TX$0F6D0)rGTz`nL zE9g*BC#e1Ft^d!YDHY|nGQXS=tfdd{(U4we&7b@K;MQKKCNxk>=XyU;N|#6&*=?Ia zw#PMv#?^%tZIw&>C_^$hM?Hu!2cI!plO492r=J)=i(wCgX~~919U~1Kr70D6Y^;8< zmo7aT?~;pjJiX#U;^4GK=eHVNF#Y7KEhW00^iR3A*aY~Z`EP7MhOxwLSqdm|Wn zjxyYP7UJRiR+(qI01w8Wyf!f42zzBAXa{4B;smo#HUg8H6r?r;^gAm1Zp_wYJx>?3 zBF~0LOMNjv`H>>%(Nu^S2WnfYz!5T-fwY%Z$<|rB8&}Zc+{Ai>nSp;h6G0|!`V~ET z2xmnKZ*C|AF#q0=OeY9=JuitxA#yQ0Zz|o*w9`+oxWrJ7>i!|xb5;Ei%gSa)n5{6s zTNGy(R%OvwwA94rjGbnAlWU*3v9wlRW5C7k4{_g(!Bq_a3B7>M2_ z`iaM|zRox%H|mv~N4_-Omoia68U}0PJ3!5X!8IwJ-N4{pf*RWT5TpfkxV7;8?xL~9 z^Oy^^K2!JNcJAtVb;+1#UxkLpe!%@_uxT{A=GE)VAG-xjwI`DAs;owt5Tu&G2{PTP z^Ah54H-yA#utM%D_seD5tv=JmKz~b>q+9*1rhbqW^*hv61CD5zm4g>T%)@RGyKy{+ zH(+*T*GO=X3no(WtEAf_;1iJ0?L*-=TAt7*Pe4GBi+&WZe5)Jio&(Th>q}ivo8V@* z1R|o8JK7|RuZ?uw-kquCj0hHtr~({wZ{YAjHxZ&FNB`vlMqTUJE_|3;1C1|s-KCR_ z!GaQ21EAWdfN15e1am8iIt4f~lM%{9sQ@|{c#&Dx`qvwQgG(|?%IM*FRrqsD!D7%L zpYu*St@{wpvme27zr@GS3+WwqoF~muZaX6W+y=UD*RhvdOri|PXULHTagf3QbOQQEVmVl!d+ z>$ypjWv8p&ONQqpF99DALysioN#lIZcc^Bt`Q?6?|8tA@pOE`cZ_NU(?)3O_czY^7 z6=wTC{H#*039#GO>3Xw-P!2k#Y)UOqh-bA&zkd%>SFfmBVpgv-CHB*Q9@46JFKy@O zD!zqBXkfBpYBcz?%W7oc(}vQeyjo*bJ0{`2;5DySk1qz3~L0!V>1h;s^23R6k zmkZ2{@&%apT9|0{1hM3chK=?4F?Ey5BN_5Io&8E>)IgMqea@9yZN?@q{YXOg(jOdp z^l;i4_g1>Q1+1?gj5bL^O63ulh_}9iydB|Y&dY}ASzq#wqz+}hvcI*p3aUvEHobHMX0^o zqOiC5knJkIYhyMTQQJFVvwpHvb$C9JmoLlJ6R=OUZLb&^MUTt!{zp#6G*Qdm6mBE* zK(7&UkUo$7gl)TX2l902%#i_1rK}@e=-=D9&o50HvMTjs@6g6jdQRo`&_6-zkHrtl zf0GNgT|Y{$Llv8pMRp{wO`El9Y08wc{9-n3c%;;_e-z*n1C^A+TA4>FkwdHd0bKov z-A}^eIoDi^{5tIfZ6^5vCHC@E@q~y^iKPFVGD^k~vL4&KE50Md;lrw{{=-!OIYF6* znHjnBr$&ND=t!oIC7AcV)SsCUt2^!f^iTbRA2cg!H7a&@vHh*3mtaQb5-rb7<6XCZ zYO}Kd)5@if^ji27LMdUAKTo)<9p^Fx-a=zuF^Cemoo0r?wj2C9gV?bCqr8lc3@IFHvX#HRt-Q#wpCgicaz6(BMPqy(Q4)Dg{OuPM4m*Gn=4C=icLy(!S5xlL8i(EL zaG>w~{;ko%N|DVa*aYxP%<$uTZhffmPHS7 zGbQP7z5SCx-h$etqUSAoS5*u9%;Lvu*@}H;6+mcyeO$)7CZF#DIFvSI?3sflnw%HVc>wkYI6yQIBUe}2KS zBaSaErF)NK(P{7M>ZV1os3JDcmh!^BiA(8mApRL$CvK%P)Y~PFa1`Dql;)>1t&8LnwSo_Rxf5M9t)?NVSG3UGvpD@JCIA z4xk4IJ=wR&r3POhV}7()!T2|QQ_#bLM^8xnc(Q={;S6^p>ZOnXJ}?LuPXT`GM@jPI z-Q6;bZ$%q3KhTvz;6HfjIW4xac2A^|yfyS2M^eR~USg>F*pCLBsczda{*TX23#wJ} ztsc?hyK5Dg19<-QtUVO{C@&1s+{w)F9e~th18ADv(|MF9T~;_7RZK+U0PueLgPzC=($UxOw zKF%B%^{uV_bc3tm?x6V5ftJJjKvN#>TUMN=rQ1q!B}EdjXUUGuss)Q*w~`naxnDp& z63`WH8tb^pV`4E5#y@Xv6m&B9T=cOqxeCv_8jx#6(d9G!fwB>cHE$yyrqt>R-YHii z?sGqw(DL0BMUhV|PhnC%&0saU>|hQE9ZiT+BS(iltL4Jw>1YtVZMelQ0>*?o4<3+f zGB=qtr% z%HVev4tw1XDA7&0w)5$&^KH#UOHaSEOUPY*TU^#(y&Cf^n?N}x&g8ACXc{A`KlX8~ z)>7A1AKl|LU*0dYkv+_Ng{n7FCWDAQR!#_s9qi8|+#1e`?Td{Hk~^(-zA_FdddkUA@C@oAIO{eScCDv&w3ZO~QvdjoNM7v5 zMu{AL*qN{n0F(#gIx+F{Q1!6FFxWzSNV5QXS9rB*zZ$~;gHEgVlKKde1G z#6i?O2Mk@RvPK_&uOSpA2S&a$45FY^sSrf=LrF7DgZ3)c7B184~Zr+JX;oorvG5+;B8qLg+#SBpJ}GyE*7*$8*MuseU&6r9;ZKj zsGV7oUVnb2jusWOu^PYf>txmC`+L<5vecd_YaO^Yfat{r@J<-1qEvFA#H*td=K$s4a5)d z$>;0M77;TR38|&Al`(IWW8bg~C7%9?!GST)dVV3q=f$9jiixCz_s6Jsp#G(sT=(z<7pQa@;Xuz^laJyLB%ZMx%7OG$XAck;`YYvUC=2@RJ!B*x-DXryQk^ zr^}x7)htSv1BN{~L|>EpeFD-pG6qE$8Ok-%apueY7wjvYtHr6!Oi}%06#Pf{VNCT;CrK2QJ8k38%1=i}O-1g`h-RDBcYB!+TI12aducwBv)Lg%q?=5? z`SJ|LCH>dIPkWp>cm12zc>w@RDufeHi;*%xhu?qBwwuvA)I$WJih2L-f##!W7 z)dUp^eZD$B#bzH|QSH3zTC*Eh3(fXD)G%rU!N_MR3A0m-pfrmBNrc(X?ng z!t{g8zd;2Yiks{popY})OrWNHWec}L@lUGm;>8VmN;L~lgNA`=VS7Cex8z>{=#kU8 zWHIR|TDJE%gTCezS~4&rdmdyYxiR(6?3nz|h+4oxt31iZ@EgKLy%XeOJ0)Zsvyp1Q zf33#^(qks8o_V_g1cWrVWp4jCcc?k`IA4*)M@3mh;jhD#ydJKDh#C+B-Drao6v5Cu zo;ZXuzw7S^$yD>Sg5mG-xYLBLHy4L0LD#_IfA?LgOVCi<_roQUFz-#&j=r~JC1!-3f?hNRZa0$MVNyrlKQ@+ zSe5soHO4;Of-otC{dw?MKO2t=z{crX+mgSVR1>zOv9z}Q>XV|Y5QY4|rAR^$PObwt z#mlI{D|fgR>X)CqB7>7=rh-_R@W`}I$CG@fso#^=u3D;`ncj_=k6k6q z(NY5Et-l}0K~V64xa}aZC|dkUgqqaGC@)jzXWohgTL(2yz~6?4M@0qe{iBrCOtg&X z-N~cvzOL>0ykBk)JR&fqV=jdeY(-BmL(I}~bbs|59?suJ^2kR^!%sw<6aM2|ok3}q zE->noj~<4b@F*i_&T7bhD#ZIge>zR@fJ{(BfW_STHCBG$QCvm&!$t6$t=$v6udJ={JEizHpnVaFuXJjmyQkG^y0YU@3p8w8P{0GvO&;{4D98X1rb=$>T ziD^*bo%=c(XCM2lE_)r%w{xd_PsIdihK&x{ky*pnWJCgE?xsuEZ=BX|J`bJ|wc>9Ly(lGLbd)#RYW?e~kFi>H z+C$PANbs^Hm+$-;0On)LNWcm4yVsFVK+EmxyBW84;ERHk+xP|aE5RQGaBruAaaewS zCH&ufuX!@qqxWAfpc8eXp>lmcz7)uBC7lROt}pmriH65)gNEc*Qw1tY+my8h6s!M- zt+#-xs$0K@X+gTALAo2HOQcg!x+|xkowl~_4?l5{eKPz zV+)SMIeV}5#C+zQYj!$1TUBGN=Gz;7$L$Ypv`a!rgxv1(ii$&7fWRiv+^06D7Za_P zga5)3OJyn>mvU79Y6~#W0JY79E`eJMP=>}CWbvg!a5ZCIaQrhE`cEgpFXj{a=lV}? z@fd6#eQ0SE$olYYkoU2ogj`I(JsC=+#3kW(-kU3KTu=Uo#sdM|e*b+H?Q;>CjrxQB zA^O;>s9t^He8}c9c_*2yK@~rkueE%J>6`q7-Qlgsi^K6aHTAHQ zyy@vN(Q~vEbP05CRUO3Uv&h7(Ej_)Hu}aR&nTPYaJe$M!S38?$dt7u2RC^Zid=cK4 z!qdBS`*M7bC+@SV)U*i5$2UJb!4y zcGen4fK2%%E||{%Eo+N50=4%eZ^L&|C`ko1B=%^vA)qkq%f4;673(iq~swy-jK#YBzTQCf zV%%4IxMUX@A+4rcQe*RE&6Pigzm45IELB*Ij<6!PNLyhAJ-5j94latUS$Flpdz)1h zRy4_4R zJos#=F`_%(4%~gr4yykw?`k=pN>JfU3Ht(q_^EsPBjzL#^*4uOtQ7@NH1@!RIhh4I)UUi}4eD#dLH}@Nn=*+2}50mJ60_X1L+d}FOM-hWr zB7#B$zm}awy-2Q6WCxv6Y6b=@KkZzP_DFpZpQ9_PX2qSBj8L1y^XlEBBk`+3+v#iX zTP;NwH**ppYsfKcwYs^3vrM1oClsI-JgMqm{TCb zbp5(8X6S71)wiPGJJntEDQB~VW+Y1Cqpw?7knv@@&$~U{S%1Km=Ea7v)0qzs_|>vz z-s-uS=8L$lAU)x|KBhGhe&1ijx$w&KTcQP7e;}I9;;?iGvzJ`&*OYA?DX7^MhTWXAUJW7k-KU~xDj5HnE zxrBM0`06*80dd36)Nsn^ws9mjKe(SvzSqbPO%CT;=Jy@>JrS+kw6wJ&Eb+*Y+wk|V zFM_-%BS|calfmF7jk}U*o~f8kYKFaS;%VlYau8!}y2qwylB23t_Xhp`;DYs`ney3T zUn0?QE_bmE;d)M>4oTnz&im``mQm-!@8vsdmzkHszj{T4XAy1g*B6;HnlAgz$o2$| z;2o*l$KFvWyT9SbQAB4fCMWpb`C^Vq+^V>$pDLGM!`MAJbxXH*e6zi1pEEQs{T@9OCZZ$5R>Fh`|Jx|1AG3#(t`RKQ4%JXkXRJh{wyVW3{iFXN( zV={HqMtaOBrI0J+7%_P5+@R%lOb|F+t2PbCCQ|nYk zXM}V9`Q_EPtN@`3;$?2NpS*f;oZ1|_3ias3WmnB;pDj#a3hzr2U+4YBa;KS_L%WBW zFX!IA_rET4M@!wFXjOZCmjf!}%S*kv@=I}ZZ<9zO7C(-6siHfYJ99{41EdjZl1Z2T2a2na03##+R)^vupIf0&4BS;U`+a_7V)z?MQ6UafX> zmd|uK6!Bl@TEs5A(JYSHwaN{Og{(+XQ^cIWiTP){8p)sb#6gtKKw4SWJa@a+5kTUS zg(+6*+i|zOfpg-oUiTJ>QZkpoY_^Pj(sVJS*22nP*Sf8dA7mzC(}CPhyK1bOh$4vH zp;wXeW)^yNSA_fKu%-_MiHieff#rYq0OVJVDvEQsmb9ey4FTv4yrB5@8vne477Hl>Hra2WVHW zl`JBArI8BN2Bd9qT(Hpgd2>xaQI$5S@fEZDbZ6(PJB$~oFJj zBfaXx&aAf3@e(F@mn_;GE~KGk3qRL1C)ezai3xZAfbC=M;St-ESY*>TW@gWZN6ceL z9&`IvJ#Ku7nmiJ1&h>*~H1T^>M@`jQe8NOT-;@CPMgSL1H|EENm+}X^C8z19tVAB) zY{m#|8btTs&C@@O9m7%*UB^o){IH_mA?Nn$6`f{HjIHXkozK(kwWn)NOOhP#QUEt@ z$_8dQwdUvSFGu$aryHiXPLN_XErl>?4N{hfnj$gj)?tn`pCydawbdtnR$wJ?-L!jy zIvTR<{BRpfR6J~FoCL}nUNksfKy~?D!2JEnWecOV;{!U1FK5xo(ck!Mxu;5;g3)Nx zrW)L=czzJp+cGIP80Kj3ori99|Lh*A&0zG2KL{WOE0riWH}FMPl``EhQr2*LmS8a( z4FAoWX0zbX@RTzw?bl7D61UC-9f;e=*UbCcF1*F!cyt1kM6#uvif*=VPGjfOpif&Y zByA<@KDKJm#M87zKw_q3+j!Q{Ssruc{HKM@AvL~z;&7g6OQJMbNV27bx!o+4q79X! z_Wel(rT_jq=92X0@EgVY%UHp7ZSMzsng+UD)|QP$-HDJ_l5NU;tqngs1?En24?iQg z@1956m}(NBoXibq=+~XAM{Mh=ne1Xalr)tjR*^1vuNSv^Q(On&4EDsBq)L~g6!DSw zJlXd$Evz|&ZFM(vyAATPM)eoP@^8UEQmWMNT*3HRYd7yda1t2J9A#C4_Eu2Ljy1~|V1gq?9Qc*DV3o=|C0w{z>XdSJK&Nd4{4&afi3o6_5+l4Z=LFC;L_Ye9LnvxJD9a3?t-P24LEbn2v+#7m&O|Vz0sDa8O}$bx7QwZ|QIx3&}k+QnlY@r_Ag8l4;Ns z7B-D5w=517fKuagXVl=3fI}SMhGlL5S4Z++x%GFFEqDTB|1oP;`RyhCQn@sNOMQ+m zqiXRG&@{yn_jQ}XAe<4h{cNO^^0HemXm9mtaN*xtnR%26P|4R*swDs?tp>GBcZ4f_ z3tE%A{dD)$#plfr0P4nkU)xH0qoc{=+{qcV7K$%m30VHVglX zg2w%FA+AIa*0PFb_t~9`@qs{I!YFi{$V$M23twIkf}Wc#8y0e>o`KdQMjJh?Rm=I5 zb(985rg~wG)BNw7+34+r{r>Wri-{}?h-5;(IcFV?Y%>`tcP{jK^5a77Uuq4_8LUjY z3}iSDA6ny1KgR<;3@<;lx{T=5oi;9 z&wfCg4aBSvcP_*?<$yiT>{0~|dZgN>qdWoSl`Kk7#giLQ&#Iuf?uReO(13NfIF*Sd zo~04N)_rk>y*VnF@5vWkQ#e^zY}j=Ucy3N2veQ^GDJDJ=z5jF* z)E?<4pFFsl&o7nA#9p8gJB(EG=%Ad}ZR1Qm3T$CaXbDatpE>WjntkeQ$(Jc0cx51X zq7fe$B#jdK`i|z|r$O(A+_EmI_fwGuIHw~~%r`Gkoda5;gx=b3_){8-hM^eR6Tv!IcTxD(5q@K~)957$TdCxR}7~r<5 z>6s}vvU`*MDN)QqPwUK{%YjNo=M@}iLyIcC8~L!jUxgLNS*_PU?nwHyD>iWcqEEcf z1H72+6t{y=s|kg1eZf|;$4xvQZZd@>8%<8rtYt2qBpv@NxWM$Yw6ugV7uH|x@yqp= zZSc!N)e2?>7LqkXl!YSU!IU-dxm6u#ReZN$C+M&0dy`83g9ZF5r8$oCr75Jp(bu|R zm6y)E$5@g5^Wj#K5XWjnXmn6$e19oGjj%MCe1{c$lhk7GZAeOqTdJF4a6qAQB3)U* z>E-MpV)AsV^Q$C+A1rgWVL&EXULtfKX$$rOV(3 zpUhvM<7jAk9sSVJat{{H0A7YorE7jChcZYaZ_=U|i=P4oSu_!jWWgJbvDf+OSzwvK z$MTzQAB|@SFAzFbc97@dE3JAboq+woJV%UL{YlY#}@HrE8_)RzN? z$f5eloiE9yf;lEDS@;{#9sAu9-{+fo(~0LcN_|$&DZuwrjVOF?L7Yb`>9g{kNJ;3; z*PADeDh0JCcOGlQozH@GM*R&YZl1M@B>F>YWye@W`7H9w*}8OHPi9_MtW+#1Cw=ML8RS81N4MO9v$Sg@pY^3#Xc#{fB# z@M-+Rv?Yd6)*?=abYpy!px%@{d|5fyB2%}F`iyYF$q{w#6CKgmylbzksqTE> zLyT=s^?-!LqbsA}VR|=XR`0NtNE?@zvS)f8g<+hkyB3xtS8^is(I@icb(}?yU+B2f zwnnIsqHO9hwBlbpT$$7t5HKL)b}-stP2$OK;;n(yKddkpEf*7m3G;o`6YM(^!4LND z_su6|B<-smoGv34OB=2j%um86y+pivnzIiD>dy<6Yex8PzZqtCbOv9N2|LvB{!I2* zYmUnAV96n&R>8-jbQ@?L*6UX|^#VWGXueu)_!PY*iAyCXYB`-92S-jp7%nOSC^NV2 zBI~4%&7FE`1v}`h&4dm6QrIVU_hj4J=`8$TO=}hTXzNSdSXwU19v8zThredZnp&l{Vp%44wfRRiPMX52@|`}<(7TMLK*3H!7Lh6%vjsynk`UB zcsX_704yLykdoY#>bK4^6Af!6=1*IZDTaj)*fK=ZrSm{*2qw9u`j?Xa3zR$(puduU zu~H#kO-M7DiJA;7)8*^T`I_y8&sXz|yF*5R=gP)srKbU!lc2Y*!zpJNbD2}fY zz}0V}3M4-DhXT$R~7GBR!^MxwMoHu&YkY^jZ&4 zKguVgH5;)>YFBL6gKbO|=`TBPb+02_C76t1^(DEjZ+vF(!(xNWp@>1$Z)h37eOjHl zEZ-9@(4_<@T5~WdFxh8_Yij#uH+uLEomDPL^Dln7jF?6)9ZQ)HC`{UtaYOSrYL||r zH`WMb#d=V|knp)~+pj`zORqu~m_PXTtx`s9H5`qx*G&u{I@g*E4QX|&&MqVRheYCt zO9V#{&v^r;iYo@gocHWU{xKxxIemxrr`reYt-}wcz4vTh)BFCt;&&rWyy|ApZMjs4 znM3Nj2SnI>t)J}$NRaP~MnsUw?)bdY42CPzD3j&A{YE=gE)BUwuc_J&AWy-b7@++k z5l4L7NT5~&tpIkO?%P+?%lT-=_zCv*w%YU@FexNjuqeyq?kIdJn=Cg%bOl^K zd`c3zEZ9x#i`z*UPGy_ZbpfDUgO%YDExM`4@${AnZ&9H$4B!Z9*1y>YgbT56!n}|E z$smw~0`iDBTpbSNg4Y>Rk){)PM2X%}VQ@t@ewx28@H|nwz&H+E^ zdp#I(g>!jtzO-$!l{X;V(@SLXVyUYIMBVecLXJMyixH{)oW@X{&UT4b% zp|`Go8Du4Z@yy@yX{x+=o?j&bYdO=Psu=r5StGFOYo__j7dU(>dtd1sa$eZJ?P7M$ z+M^lH1RFfvS;b}Buaf0@+AZ;XpWWWI)ha715#>g#^iV9nnlD$uc~DIu)vaVp&aEW+ z#8D@H&bnT@$^u@DQe1+7$xAYsVRC@=cbsa2tK$YqUh)O#a47k|FIZC2LS^V75`(&o zrF5O~6N~=IC+;9`POB>rJ*AiPN$+mjVX0jbe#zflhim=F^2)lIc)r_TJ<)g)P<{-b)ZXq!qTOr0}BapdQI zVx2nBKZ}mhw$};>{Of)GH=!|%ngEXjn?e*u_BE&fYsLhn+i$EA-?$wWZA(bL00z*r zQF(FdGRNc30man#*GR?fKe`$Zb?e=2lmXJu4d|dmoK~CS0;)pH%Lr_$`iLunCHj{= zfDsXGTX;k^F3RfYJN7j#okISVhVoOxzDzt8iDYC6BvA%vdey2B3|8nqX;hU$ndHy7 zj9p>U)2zCeoZC{)`Vb`CZbb!=1z{KNUInc2c6PtsXCP0|A6CvSC27EsLv z`SCDKUjfbJC;h8DuGexY@#*Yr_sLL00wApc%TI=|RF82MzkK2a1}bxj$LD@NOb(};3UY}|STYLVCc(V9 z%^5Om<^UAp!e2hbSuEIXsiTSytB*BI^fHj6pR*S0ylkhP-CEyt+@58pL*X!;^Ix;A z$6XVJhlfXIIGXZ&q2iR#2B>VP&9{#L8B+j+wcbTXN|kF$!U9&SVYYl=sWZCkM*4dl z(EX578OV5BAVmeSw!wb~W_W|UPNeRO+^Z4Sr<<8?jz4dWF?2|TrkL0)Rms3amFU## zz>JYdAR{Y}-)=ui{tKMT99c*(uwbx3^q|C6&(|Y%-*RJ*DPa3DB z1tZ!{l3>Hgh_W7_;=|(@g8+N19Ac)XiC4X#-^R1%xAX!9(SGyheC>hnJF>INags@) zqpx^=WWq^o`;VuzQ3)MN)`9rX@1Vl9P%LD>spiDfBvbBIiTtT5A2V<;kVL-dp7XQb zm&JJ3-^K}rL9h*qE@(@WB0myLXYd`H$(Nh}OHaVx!&#GF$^*3{;ZK`?*u4c$p~2&2 z#Mk%2FRHlm5g3d#u`;HOs(M&h(3vff)6;kcxIuh;)GkZ3@D-oQF*GW8F9P0c9CDI< zV%F{;lIHuA+ZC4)LE^2gmm!Q*$t4_!!x+hwV%<3 zwZ7Xzn^HR0z39p#5ipP*O}SR?2o_P+X>c7cV|=tWMB|9`Wc53(_muJWYwAdQjaFM- zwx!LPNB4E&dbgZ(HQaov8p>=yRXf`qbp8?6_Inf3x#>RIwAaZ2o3Qhvn`JMhtR4pM z9>+7;Ptr-5A7|=KyHSu{ee}s6+FvD&xl-7+3SmN_M4b%wbA9fNO-ri-V<4d zmcLV_rl75BZ)s<~4X>`VYzKEXzkBHKQg?#|6!SS|&df))$;WM1$zzB2r>i>;=OSYd3&;3p!KwKVzvdb<#}jouhP%as z);%Apo~37EJ7~F;AWJM35D#!Fb-M`mz6AQ)^*Zu>v4{)e4NEuI7N#X7-BEu*K2R-~ z{^-Kt@I{Dw;mZ%?fv*3cOjBksU{n$%N&#x`k1sT6z$h(qtCoM>qi&UFnf+IPuXih9 z2`3yL@kNd2`pu|q>y(3!sjY&o>jhX3(KPrIx@)zE#>DgLS_0{F{9i4C)=f*mQ0j@e z%<#)eNH41hH%PE%Tx}dYdbd$PTM8Un4GJ$XFY$kFJvbGjN@D&5W&l=pw@)}Q8nO?= zZtjC@K$)Oeyd0@Fi)q9_Yc2BC0ztC#D)R2ql7QH%k}gPRhDdd`9RMLrv;H@cP>4_b zUyJp@2}ER^3C(|{Akh5y!iabU#uAaz*am_|Imt5q$Nb2g3$AY5UB;lZA;~Z~(Z$f# zw&Z+H9mJIGJDq9os4!*lM@$au8NZPVZ2 zuevM=LAcS;&L1AG!+=E@%h6#_E#;cd+Cn=>iD$rvZD{tm_JWK}+0tHUWvBx$(dc}-p}vf zIGAy)Y>%Pyd3JI0Y8kQZ1yZB<5fG>$dig+c({*f0ApkBO1%7bPLmHg3=S;dwCIm|? zNQJAK_#6g~Ux|;7k4=UJ{CxOo+vx_I&>H34H#S;4cA-_TE2r7H?G^_f$9s`N(-gVQ z*R;&7*Vrac!dQNaPo2!6k9+c1Tx%B}^=hkJXHTMchLRw+inSZemw%6&EW@E_DSQ#c zK@#%ki(f!Qdi4Q{_(058YOM2n;4X?L9`QYkk>49mEmQMXGz&0Z`>1$w*hR+yTR*dx z2)Ftu4Q^_L>{{=UPH-_dQ$UEiA&1?85qLj}7Z{VsWtzkV0v3zTJ6w?^uRCLFIE0$T z!ZQg%biPgXv%By6cVnoH9yS{{?d>l=evcKwhVu5j%{heOfO*P<@YOF$4D&hml?#6{ z?g?(DCC+g~GuH*FJtrMA4T4;25!|L$rtCwg*5cWBDy}*BzE!LO$q3k3uX@w)I*AYoKCn*fxlYJZ*DMmEz zAbc#RcTR+AqR8)ewyPpYQ}qZ5MrnLZTr`6=7Z?a%P*~-VN;02KXiwdS5!je4$jYRb zlyaSmIwi{*M#3l!b>G0d{UNwr(0f%i`?fXH*Ky-#b%B^rfNIbbgZHLmeZh*-mIwz? zGYwd;V5Gkxa z0w1ONL^`sRbyn^B=$O+Ga`SU(>6n>GD&h$&c9fkZ)xH*ODqlwEdN>M~&R7Vs2sE-z zJ?ypIjdC;;Y3mnwU$)#e>ci+Ron}ra4`(jWC`@)*0A`Is9I0$DM+X=$ODaro zzNkp43bW_!^nI>S^)d)8_eS>Jb3@9;ez+%3vlT#SS;b@3G$i1-9!L6KLkv)H8b19Im#J zLIWGEMc|&Iy=8;SZvPx)X3bb@cQddVnIa~IKbuswC6ONMzv>N?`aEi(@(y38+PIYn ze9~q0<;kPt6+OJZzUv@m$0_q+dm|;Rx$XBFo?H$^4c>@8l`8WVr&E~M?!}1(JQ-Wj zFU7%(^x}U?i)wKwngJiL%~3u8pQ+z_L00u6ojIOX>qnSsjt^d*hVxHnFaAiOnljI9 z3M-H`6d?3#<|H0GZm`geZy)6?knM$|4rV9!@nXEK(faLu;*A7kzi8$W^*t(q#3M+8PQ7VCm%mTMUvw4k< zi!L1U{$A&un5-!Egr8l+@OkheQPan3#fP2);A7Z5XL13owH~p$7Mf_igRAK}r0>O{ zl+usnqD#xn0wx~(*S?Qzgj%AD@zCB8I@j4e&jW^-NA=OZ_6+1EG4Ocr*9*FKFc27p zXz$Ggrh7B6eBGm)SY^E0+Gb-(bCNjHxjCJQgjmXMrQXa(V}>~)SnZ`!Ih%>}LX8R= zMsEi$_Vc>p=~h!*3P1RKI+z8A<_9yrSyr$4c9*{VYJPqvz zlccdz0@|v1^ulOfI{3KuSAJ?F#WoCC9%8sAiki49px4;~85eKO7!Hx~1p{mcUhp^F zdqjZJ+N_P^b#RO|7qy5yl*+N=1?X)=T5Fp|ehBuCJQl-M^(jM@9)ZH4>EN(tb(HDX z|7FC*BtykbuZnU5-itAvCejm1)l5?ST!D!=Pe@&_j0|_Q>x3yNmyDKtzH4yBEXBax zUUfTdkTY!j9tD0GnZ9BE*9qqb0N=IiA@(qRj_V}lern39{V}>63sJi)e#49{TUUf* z=0&^0aqQqGK&pGso$Z`pQ=>XJ6^TUlTD!M0yKMUg_0 zLbLF0XU`dpqLk?6)u*3~y?Sy400FRk)QwzZtjv>eS71*nc&thh$Rt{TuxUc8e#*Hw z$zF1h49;ws1)+p_Z}jDT_+lTcmzO4}R3|*uz8s=i7-{Xp`5~MR;C}Sxg3fmW{)4Mr z@Ioil$7Ulg`%5H2jwdl}c|xi5t%Yw3sb*K5F@|`jylLPp6MAT$>s4`i?Fw$<9_sv5 z&AW;Yb`h}v+C_*XLD4%({9JTFN25a-xPg#-gJIoq3*!qznXd~` zHGo5ulLv_SeG#R{f3Va7kT+YQ3dA4u2yE#VvczT|*mz7f1Y0$EU&i9AzYLl2X; zWDN+3ucDxI+Mp9J0*}7F;~hS_IB`!8mURu0hIryJ+%r1ON>EDa#EzxtA1E{&soh+1 z`Ww>`U@S^S)J-qNCOSkrQ_L3Bs9$3n9?$yGZ6virg=KKi)MJUBn8_!KJPnT*ot3U- zNmpGuvu^Ax$KUa<*p))?&s3-0GZC;}95Se6^Eb8OtFJ#oGZPjhx*!iD#($>LIv~g; z?A)3+<@2Q)7tc2CFu?hMBtRW4y--@FP_IoOtG zLLhA;RkifGC&RgCF5xOj2lS@CIDXMVhFS593C7fYqUPN`yUXror@>^Dlic@VyI>KQ zUezM>6u$gc*zTX-?;ftP8f92M>iOf<^Mb;Fw)fu#lEyuJy~(_<{jJd!W`#@&+;m#Q zgEc+27B7tg+g0tflMDG^Tu;mIFG03FE_R%XwET`t!+O|J@x)oI32FJfpZ!9Bryhy> zZmo7ayEGm7yl$z6co6#poH-MqM{&bKC6S7T&x>fM7ImjQ_~lB3Xh73s=8ELhHKR)W z98{_&8k}B66!8@QyPOc@e2Bi%LdHh1*xjaoG9ut=K7|2(L+$P!70kCiWP03QP7%xJ zoyzic&}hYFbSJ8OmmkQSyAALg8l4ZKvaB1xjyfVyy?Bly50kV&6mwA3`2ym23tdfP z4b7O$eM(-Jg%r!8+hpFB>zPQk*!`gl5KLpmvPk_iXT}B}8EfDWh3(1szO4p^bw?i! zR~=6e$pW`*4xe}$-4kO(>H0%4x%JDH0>gz&WH@ca&1NTel@6(1U}~EipN=NXwHXL& zlU|wYtLm>-;2DgzP51oe6!4fSY=2!V?A71?-tToJR1s=NOkNk6O}N|OL)XW!H>&Ub z4FXwJM1=7U2*&Lwu`6%ZwZk?^Pw zpA1F?(IXmv_p>2am651nAWBF+L2hVik$Jq(IVm=pmx74H^KxWco&aJAJ#ov!<-NO^71$jZQm)f0VIay78YZ zFw+9v{!Byu6KH~@eLTKsf}+SOfmP~X^{Ou%j2s@bpC($X{dU<`Ah!LmyfY7_QF)ci z2hu=srOF+N>21y85-vmYijI-Sx~az5fYQm0;r3_BBB_sJY)@c6-XNb^x0j3L>ICsm z<3tdu`7z5$%@P4TI+__EdImCVSO#rFAtI@Sp;CAn#V)_l&m{x{pbr_=)GVr|1q^Ry zqnU{8i;!8krB|O)1g`vOBa}jT&gv1eN}wH!prCAPYkN;w8SOh}<+ak{Hl;zW6#QMP z=iv*NnrVKS?m#~Y ze}zCf4QQlPkz3dG0`>yn z)#Za`7bcrbF<(plA1t6DOU9+vWp`T$oat zuG~G9cG(-RI<6!3yxecq0`$Gr) z@W-pbZoRr_S6YX@`$h9m2@U<5vi;wzlvk9{Xz?*X(H5tn@f+s*r~c%YFu420W1v@} zRry5C>Yg&PM5lS$-Sw)Ne=j&o9TmL>!H(sHmAxiN@7bYKZ9_ifA?&L!jAp4>nJR0) zj>w+eoSG^2+Fj>T{>Y{LH3Ae4@mAA6)UT1SWlR)uRAD0JGx+z#>ITb9%BZZx)F@&* z>J!6qKPemiN};47J9n!UWBI8*gYaeVWGk-VL>X3GZ3W^ zW$*{HyYgnP2rhgd!Cw>#QO#A#`bP@T6UJC-A7bMx9DVP9!`RL;X0D-{g%KeYX~wBq>an_LTm!amC;~>gKy!-)~qwQ&??o zF5w8Wlh=O-RdHVrd!GBo8)iwg4vps=4ugf1~FL}Zk9r23~$lv zs#y2r>&peM*AqB#QokfZDN=Z;tSpi)2l3VC%G8N+F!Y8et%Ae@8NjM z&OF;fYwTRl`_^r?aZ9R4Wc^h;JM!bmYZ6>abrY9a0gB?I`$#eZ0Jw#pKU$u^^a)?d zK**TqMce~Ha9^|!u3S|+3&vmN>DwdUN3F;Wr|$(m}}=Knm(7W z$%W)O-P!;@F91TYPDY2UP%(cHnZs*!Ryz88MC67~@hix7tt|s74EbBVMo~_ zFhcdmC_`;LcN|iQ32?@nYc*h=G^BP$o~@fDpQvK=3SEuLQYV*ZpbpIDbeO`=VRKqB zkv8>I+K0u(8j4?OvDf{$C$o&v zvAtj5167D|bBQrwy=U*N_!YMM#xq9K9htA5gwQoXf+b%~R^4L_-X|Ic#-QhI6EV#R zwq#L7M%cR<99{fB z+ZDrJk_>wnsiRZJ#&!dYU3&YwPh8)u8Cc%nF8f^R4SKJS12>>bhheQ-X^C+b81+z3 zp31{uymeh(&_fT*fRRVF|0_X%9=)6E{PTOVw;E*LBZ4UdZj)$=hISRp)s)AFi*TJ- zC%TQ@pPs*cGh@49r=W%1kj)DeM7KpcCmj{%cFq2F7zSM_FVR=`-->-f7tQ<#V4y#7 z`kxEmAn8oSy^l4X>Aa{qZ~*TUqOb?JZYl9N(a*Qz^ylW^br`^R7s;a%UC5b&SKJL$ zzo#JoqN<^&u)2jF4}_<_TrGwe1z^)3ZgrN`kh1up)_A|eql`qoO*g=iaPwrTq6wIq zQVx0D-*<#08PTA3(vG9Cw;yTm?5_}`v~vP#R;(euS@h|$=#^iobCL9p`-Gep5e;~= zJ99>5FQVZ?u{?SBIBm>-9E^mU~OXmj>Tq6IF$GU0S`SGZiOf)zX<1ZuXPh%*7_=-BH#`E=d zIV*S^##BHo4xs(4Pk2tYVK-Z!09ovdd?qMh`iFJ#6`3fz9aOBIx4ALzdbI%6 zJ~~n_?=%vIJ~a0c(jM#GOO$~WjP?P`(Ne8?ogS1DRZDBj3@(f+YL;#6M$h-(LU6HZ zZzpVrFN}JA@o!}JG2eaIIr0CR>jb>CfD5(&7{CD@h#Z@~Az{3enA+eJM_;+HKOsws zW0Aq*Y`kf_7DoT$*_|)(j0UUd+cPe&Z!6FdxK7^R5AER6`fvjNNM6N{W4*^$NDPd1 z6z6p`!DvLn-m4j_cc70VP_%t$%aDe65c1aJ666ya6YeY97ai|&dLIxsg;R+`9MFni z1h9Qu*O%q$a*?!%bi;UoVv`Hh;lrOVcKtG*~{eVqAeXg|4eHPLV zMnA|Kqwl3t_A+8;D=e@#;|Bt8v|-t+=ExocKu!~QfU}b0OXwV3(0IN09Q41t1z@P$ zFuB$@TB-5afPwV)DFFqXr#-({zuvU+;t!r9@;3e_Y|Y9613QS5$k5!yfc7webA)TK zk%x1u`;q=OZ(Rl!jHibAiNT2sgiedqZbG9(d_T03FzhL*TEy}K0|Rjv)H~AKP0uUv z#5JM6X`|QTPCFUiM)Gt{HUKxcBeB(>0m>9ruYz3n83ViFUI}pTRHa@Yr55-x`VKY% zS&a4LM*r^6kJi2E0xrN^tS87ber{Mzu(I8|iuZ?DV;ZMX>+b$MOv6-OnPA!i5Db#= zF>GS22^5fK49}UUle`1S&!c5wpfDzzOFmqvh7cvYcC@7Y@KlKJxcF{&6I z>L%9JEyOhILcwV+9v&Xnd&7!HKfK4FRTEFepBX+5V>2+=Gj_hpN@$7!h5PXVdGk>T z1%bTbRx{YS{qoA3P8IEei{zBeN0EhI)jR&{W5? z9YkT>2}+UYZQ=N1rFQpWv%??yJ$H@6n=i+OjaCa9+X#j}E;rVjzDpieTB@jDFWGoH znjN8E@1uE~`h|Vw2VO#ZCJDDY3c&xZovY)C>VGuPxop$52g1X*>^K=-Tjb&eDHytx zxf-sP>Qp|Nk8_J230Y%q6xRXoa3UU;*4Vb13EC_M?2%FHsS+bO&AUUL)!S1$s<7|639YyJaq{ zX^X9H9?T0Pm*9xmMZl^Xbdd$q_q$ZySsH}sbA!)dcmV|;;`8y43B@llW6yq4u%;<`(3K zXz-?hnGAZ})a@cFpTbf8@jEdiDiSt(<>#zP-?#myKIxpLoV5903CFX<1262|PxjOX zOU8t>JUlKyi?;XF^<9q=dlB{w`3VQko^zM9-h>IwES|fVv&)C3-2<8S#o;vWc||S& zwr|6P`FtdResVlkr)}H0(x?2l;QD(}Ety$9dr^%L_g?`=bRVOw^w9J(v-Ut!qPy7& zd&hL~-dK6(O99)ZR}R=Ua5FC~ZTyi>Skiz9c*E+);w4QfbPM9b7^tUXrHVQ`YBg5+ zrmM;mn?4bKL2{=(+Ke@8S6m(Yup%i3jL6DGYEg*Zd>gCnau-)C}pR?xwHY zEv$!!%4xXb<^-so?35TU72 zyX94j8i1ut)8Tu7@9{>mBa9c%zIs^7PnIVW4-~&9@g))QId~Hq^(w4vi@^xCrM>8s z9>r?!1#goSU8Cg7Sk*o(QhP@lxPSszcltm%QtzVI7e(X#1ptYF<%6onG zB;_`KptJjCCmbXOBL~}qa*MQX`t$Wy%jwxzO-u6bX{N7WYi%apV?(9#*!UT;4*H1u z8}xVbJb!rta1TmX_Drnq!sFKVFgCdCik}_I&QUA6Qz{lB948ZBaFJ%vONg0NIxP9C zYnOmMlOQxr9sV+`-7pQWvQ(BX&erEzJPGS1Z18|`_aI| z1DNiS;-q z<3)rX17>X0wLczlO@E|r=T=%G(TZ)%eACt@Hi0Zq(HzywThQAbmIP-=$3@AX&NPw)ZG;Lo{Ne={t^N&-mJiQ-g;+xhubYYk;(wO)gy;raMBUw>;)Hh;~{(R6<` z6qa+ykypgpgBk3)mVWd_A4(RD(1WE+$_n+i^5t(x6hB)V{$oFs4lqFsK=4R0R7VRc zrc=N^WZ8fP8(RD4+mclQU}VJHAPTwy|IgKO`GOcX^P}KYo+;r+r2cI|WbWMti23Le zeM`snp#I59MDLNcCnev5H=LC?f)Rg@iZqQ=But=V0A?g|ObCx-y2Z>Y|APhi(rZUt z(f~tbTib)aarRQVXCFKbUFv+IqfuoA!`S=Tx-TmNp?1eN2M1<(P~n(9K74$%ZuOj3 z;R2hqf35Rzpj#ME+B(hj6c=>U%Ql7Hd0o(kgHGD4NvOl4hk);AQ(X^us;pn}ezL;{ z4807Fhtfg$fJe?6|ExM008Vo9M~f3RvIwyezus{WANT>Y1$LmHk5KpQ zV3)^%!jUh?IKro^F|CG80N&V!vH75nZ=aX1=z&2Cv-nk_ti)fDmPO9|^{v|`Tfoy~ zMD4cU4=uN>4C>(J%#{=iRo>=AGP=kdIC6l1j8U_}74r!!d_VpQiHb<5Ha|`NY)9Wq z;~dgXBmG$t76X$qvBF-9j_+}Tsoo6?GZPU*We~y9W;Fou`21SF@oe8nXwK zQ@gsY3k{bZ*Z*u7-(QjjU=;BYn)OmeDgFHe@Rw+-l>}b$tgB)JK|r%m`pR;z(k++L zZya6=oCjy9juO<*&?#%r7D$i0CTscl@nZy?ITtXdTOUmlK2~qtTP|gl zbnahV&}C%s=8=ew-vA+Q$vP-syNy<^XygOB+p^)4ZR6eJgj(v^tUD0c;e)y@w7`)i zarsEl;i`2U5=a@m^ua-0l-0WS&tBrJ&gZ1H^eeQt(2w#WboF6}zkV>QFZTckH1Wpw zic z(|4w3a{=@wG>!9F7!{OIcGHByNz$%2)R9vucN({#nxU-IP8pfLxjnETf303 z*Y~+a4;jM%X^@QS6R`AKk1he$n`uT-OjcGCR*7rq8*Jmj)9;+v! zWe{7IBss0-W2K?F zX-DF}c#hw$hQO(Tpe51no+<^kcMaC45nK^mM@l)q&O^Tm29r2>HD%7wQ!FBR(kGQU zTtTX69e`H}+9*7@I_dwc%D<3=?@x%ISc9}f+E3ax%)jcc2-!CL0ITV|D#dX;Qd*sU z6Id*TGR0#3h|jn|e)S)wrm0NWv<9Gt0)GXpgy588x%Qh5({;|xTbhc#>d<5UDyL{` z%e>sc{PoK)4jck7ma2@bDw3k4D($@hw#**g9@LSl?Sw;q1cUqv<{iJtO)L%BH;M=n zxu$`Um&Q9M=DeUFW;04_NH^%~F7G4vFR1+oM3IEBF0)(V2w|ZPqW>%wa3~Ep$&L#! z5tSc&{bLe24-)3Vkcn5`R)RVu6SxX1*#-*KIaJr{x~^}t17otG-AOah7_gC`cwB97 zxJDffs+VYn8e+Q}wv{Y4Wb4JvGw~RzXrv}#>IuB*(gCPM$j3|v{yEgV1(+r?ln$tn^4UnwY246WeMXY4+Y!Y7m;??}8vrO4uwf7ch?QhGDeSHg zeiR3KHc2Of$H(D})YMS-=qW0f-Uv3zO+V_$@6?$^5r|Ra3@z{|5qccfn)EPijYVXI zj`UXTrl=k6|Gp*Zr)?DfPZr?-S)>{zZ8F-2yzMu``TyWpkVo^cp6TBo`G!C?D!U>O z>Nd#ueEoY9{{0_7(s2b>C;snmhy2JF0U`{A6=`1}MS2wmmHkHn^PA)J_nJ@``XK86 zhK5XAEb!}iMv>JeV9w*AW)%LdA^$5hA0gQX|NGD1;o}=bMO^)3-2i<5n}pBW|K!kr z{`udU&p-dchg@`iNqGf$@rrg*x1~cs3DL(e5!3XR@gb7U{}`nI{j9tZa3K`71K&w# z&qaQ!AXHYgW3M3m-DZ%_m8pjf-hZhQ86J>$D0{&1VuRNcr-*tYdq=d?DKQ>YrEn1V z??)y?@RcU0@zml4uSeKZQXje|>4W`$DgYqp6P>?zRo+UYQXWNe<#wanxMnb1NrQ8z$FZphx>o(rP^-faOa+{C2n1NQbM^cni-9rLOYi84mdEjX{~ujf0TxxawFOa1KtQ@fLZp!{MY=)h zM(IXCx8!;saZFsJDk;rp=p8oA?}OEuQ%5l zo%9(UXv@ny+0uV&!8`jRML(wN?0dZhTl%N%S7rF%4mgH2)`C{m=ojSojk7C{NLMX| zVz?`fD^f~1M*}$v2Udo>G&B?x=V-eCJd!+K&;H=C<|X=P_GpBkZM7XscmAr4e3Q|+ zZT(Jj}Z^8OMUNWTPPjKxzHnp3?yV~~5p ztk&1J{DE>v?#|Tb8OC0cd*V~3OQL4$YIm&=%&M)uV8q9waZDAFX&;6bnJL#5ANI}# z5)tdsVXKNfxe$sUlV|`6z-+3t6Od8~iYVq16*sOCH98_wf6IM`S0ySTsDx!->Mz>t zA_OTY!{;b>HqJP#Hm`ArX&4xmX$!OO-wllX!(MsTu)^py2RaF9U(6B{E-K<*fS$_$ zz#GXU<+i=6mS?9I(I+ThB9nd}z79nHxVM|D_%L<$*-6-Nbn6|MKc~`NH&qPo9f-69 zg3;|kA9Dl#c$FccldQVXjHRDGn&ZJvX6m|=JOE&wnVOVk^i?vPAclzpAKmfrQDAo* z^~8P<9_C8DmKX0tg})RBK>-eB?D z52J>+SAES|QhD#n9>Eq;{UC->H(cyBq@(=CzF(C(buQdtuqmSDkm^nh(qIN6>`OHI%i`YU{zH@Y9f@ec4h=tJ#K(#b#E|h8-W~}YZ(ZO^^-NEq1Mk$T6eWX4!a35ZGMkV4UTuM28FR#b?t-j3j zY5BIgH!=IYg%dD_gBk!PKIX5J9c8&;*tfM5xZY>EiMHgrU!MOAZZhtNK1eS{KYIu6 z0$)>{6hT!?_M;%-3V@8gxI2D=;<;K%Ikbp))>&QWtwjgsDC_9Pu28eLRO6jcEH-`Q z%<&!D&30p%Ufay&B+9O!rbxZ%B;=d=MA7+1$P1Dt&_(-%vOB4h{t5i{xQkJh+koh{ zHv9C!ktl(1iP>DOy}DEh`TllH5rK=i73p04&8m5}Z(FIn< zD8m!)@m}tKsC)i?#`E+2=JRdJdD}D$8O@TFi=UtI#k-~49rTIB7d{9zi|~>~62_2cvKjuCXarZ?OB>Ycr&o48 z4YGrdyHnI!+E=*~ID99ap9QY3>!`I)PacjK;@n(MQ4d{AS0HP-b{g%nBBv{-rtX`z z(#dw6tUoZ-Pi%&6J{Y2t6*vUC#m_d|s=&Nu(1Zsif?x&}f}sNNp!Q{dV_aHl%^Db@ z#h!S3q=-CzW!&25u6EEH1<`W6$+z2YZrDahcD9XbYAY{u%jzASW}T5onmJ+>BYnFC zV7t70iZaPHaEU>9!qrv9AqPuuJKDaKwZHzbUe)3!ZtM@+ivP&7veetX`$hb&6-AZo zBD~>sX`%SiELfYvaF&&$`1~MS_T##%YCOIT->Jx*aSe%R!SU68emN1;eBQ^0JPxMq ztALMNTEE4my*rj_d*{{=s)tI!q9l8;`&H&v8bXaNSmKgtmykm`E9|8eK2Vn1^{q>$ zO_dsS2%uqMr;VKZ*gD(qO*k{@->Wi(TdY@)O6As$`;*cSQl!lPo97r z+d8MZX@!jr?pklvt*1E+p68~{-QRKqm*1CY_| z-AYEmqSKd^5a%?%?~Z&tX)_h93LCSK;nkH2FyHEqEsul1dbCW+jAJomekQH>YQdqV zv7*3j%c%5sE?`4!OuKmsL#d<`Gn?(ET}eJSdWz{;2r%adJ_y0v>qUQ*hr&C#8U)VJ zy&-N^sdbP=3U=|&$vQu%+)ELTZGRxkDH?ZiTXMu*8Uh!IP}yT~>0QAf`-LI@%;pv+ ztT2UZ>3qhW*N{dSHin`_3m`S6To^*IJGAq`_L|wnj`mXls47NlymWYG72YcN6mczJA#-ScJqNyNgn|_ zx#gstqq63N;iR3bA34#mVLZb5_RzH9r0hky0Zp4*B*zZ5qmVGgksRwveIpqKgNI*e ztqzTs``t?z@!5~ml|wGZ59D#sM7fXD1@NznmM9o_bGvATJLch&__ZN5(j*oa&(#R+ zN}VcbA-W6XSQ}7vCnWp#E^VP#+sb%|W+_8^3{2 zlMxv*GB7 zjM?I%Ew~|lIe^Gky|TC0JT+pJmoL{*!~#N9H^7=yWfVEXQlZ*cxb|5|^^@^1cY!cL zo^cSn2>lFO2wlN@;E%p)klaGOF|s!Ob)|4Y&VYJ{f15|n#zf`dE0sR>siJ9v8g4QI z<0MR*>FT}#i*ZUmK6=*7o zxW97kbyB5ImLr|iKHL$}t*nhj%!R>qKE|GFXE~E)T#ssO{Z-n-g-eacX7O$23x+Pl zj@8AX&}F)n+(<@hVHH}g8>8wHOeCI+@ciS-xR|Kn>)9yol#wlOdLC`9vdx7VXubT7 zbluHT-P+Qbn(-8=5<*Gk=K-%A;0$~$7|3$@ZHoE%r$iCE@w%yH zQIG2DLw#_9c=|jhlR=YpMH%|?=(4bj%WB-!Ye%MLv|Yv{-nl|m1Cc1JZckH-t0OBl z8bVax^zg$$-Ly+2Ux~mBIo|z61TGnkwQnxxFJ!JtUmOPSTCiAcWQ0>VN)bVWWDt-Q ztR{)5oklhX7>Or~^t+Vyul2jOZf7b11U{p$CmQjmA_mS9#=a`_Ja1atORBLZc{jcW z8U?PN0f-G&A7H!a+N?ipui;Ur#m7Wp@oSkbHr^?Z-w#B%gD^H+Xm0XkrCr4%|69u# zLH~|tn4H;f4d-pO-v<%`P-c=I*8l;wcws(5Pu*_au4)S*bgQu9>7GCictKEx5&$5t z5^wRQp!YJ~H&%SpIoXNxEd6X>K@R2?AFJ0o*^0C9P#wGIz!XPil?uhUOCBGCl{eOe zYiNZtQW!T&?uqf3KEiG@IYlJ70xfyxen`GFC zJw$Wc1+CLVN%!mZP+4eOwdnKeB_||Q{+0b+N^$MZfzwKnQoL`hatH6<^t~O7hU>p8VaUYV!z~TYidxRsykX5k2aEyk$~P35*tjmIH9>VRe|B{VqsqxcDFsCOSmOH?!XQ6&|&X%uCb6C_;SY-jsgCuCkq1S z`>~3|J2$MCjI@)@%@US&b-~4=-x-o^)dCI`ne&DZMB8KJb}5|<`KUE^+KrBiN3!6O zV6|-CJ|TjJhuMVn@O34Nf9`jxS|xlIyPLR$)<-%IS?P%VB8gXp*y{a zf0r%rOT>&2yiY-D@q%#kV`t;VX=cNG#{G_&YL~LD4tIv@(au}-qp*SfU7O5KJ9w|! zB_j8?sg@U+4<0%+Vo=ozB(kI|HZFfl6N`bP;rd?O==fs1o+W@ipi&<<*wpO9*h__^ zWWBSG??s|2ArP61`EXnH8Vl%!DarM&uY-fx%(jRIE7ux_Y5PZ9PO43Rlb){%Jfzt7 zJ50_2Ng2vRw1WJRik}yDv4TlE)f^{80_*j@VgQv!%2}HNxt+^vnK1IU;cEFY@+{#F zD9{AXK(-g}IovN}69f|ZzJPJ(-)S!ll1vbLl9Of@5Uhy3VT+Z@yiPAN#Ya$LfPO1sSdby70kbmR9v z%aFLLGO&Dr@<+&;?scAr-pdV?B%<9z9%#1D6P9m>j8D)DqC4Gt361fVh+Vm_tv-ff z&eW)#UWneFUh(h<%&&{^!o%Rso@h)!Ty(W4Mron%AGYI2sL}379R5hNY*Dow-WkEa z9wvVGT#!FPt@d(J&iS5S<#s*KxO;l>41H1kG2$2o%vE7>}$>!%%~Do zVG3i;TEx#$p=ujQ1uxRx+B<_McO|+|M-4!7K~7OYt{u~>Pl-d>Wla$h(y!)o+6<(~ z&L#EJ2=T^S1RY^Q85=s{@(1L>wj`dz}O@p1;Z$Vi6agR#N7cnp~(@}MQ9|?=WF*eQyQQe zBPEwH6VS#FRuh&dJlB=1vOXcZB7EJp9pC+%Lio!W!DlmCv;c_dh~F4E>G6v9(e`y< zWU_WJwI@H-wk)P4GAL1fcUYwyc83z*ZgeCW0^vp>9*2BIrPsc_^mlh%B1yBDBM6A* zgiZ!9vc}GChiSf??q5@W_DcEQV=Cpkhl?HMn*0Rz>QdY<&oSi#4OY+_&hJItY6+>;>Ct#^XdxJV`I9hl^Dhco z0`*?>K1VpGk{)qdNx5pG1Dm?)?U|Oi9n|0~OkMAn4EUqYk4Mp@8PhTv8bu&iyoqGOJv6fNr1)lV%#-?uWlMoUC%bmI;k&( zoR;u53l}6$Xr1eP)4n-B54F^o=qP6ncJFJ7tkZ*hiEQ5)hKm_v0x(hmRs!M+tNcH# z^!LRdwzBg8;$m(qj9R!mRroa-s+f(H6@s((#pvtXVz1F%A3K^y3j~g%fJwDlsGg9x z#QMacaPV~ir_ns*;VPN0R5%O#l|}c;4o+`4{dBM)&(yQ1;Tv6>8=N&rG<*Y*m-v3G zbC?s5W0GHyJJJyFKA~~v#YE#+uuEb?T-UUjKJ`EDw}`zy+B(&~=xYYezTI##XvLLT zIv8r*fl+mri6SkLEBbYYn#~Co?!P4DZd*gYGr)3=K)Q<#9Lgu2ZjI?7Z2ly~X<>(_ z5wh$`at*1FzMVWAUIU{M1!iS)WmJYQx71C9Dyh+BhZOCuSGBq4d4&rFb!J$7)rt-{ zSuDQP{=P+UsR4>!e>ST1%dn_G)*ncQl<))ON1!hZZ{_m4NahNs){ zzoh~Tv6Kh#O>_&$^%B5lcsp#=`L`Ya`Kqy}uo}pKXHG#0ly%TkKUgHdeF^eV)_5{k zzX>s1Z#wMqALmVNHkiL{Y~0wi({{O$R^xnv>I+P<)tO6T%#QK$WV9hHA_B%^u)t3* zMLJUvaMUmPUv_e6Nn;*ab!kpaE*G0BjNv9UtY5pa_>lxrGBo$dGG*Kbem(Z?*{l0> z)UQ)acRJNjye`ZK?r_i1G|m>jNwiIRui-MaxzP0eib@r)q;R?tYN-x&b?YYi8ob#}gGcUyxOzbkQ==9*=-%9=zrdC5)>!^%uB>RF-u?OKH9t}izZiAs9>UIa zl__**su>z)nCfV-0CGjqQJjTE9K(Xu07g^@UXv}3rDfY>18=bNy%%gUIX3*M=DM9R z?rQU-W`1%@gq%pm#L=K8G&gyiotlG!UfQ=!%|AFf-2PRlJz%zt(=>Qe%=iK z?_5CSWG9=*i*b(Nj~~~>q?>Xz6xZ>D<{%a9hpY%fJPI`9eeImt?>YnA$tKZ)9-Iry zK7=Z6Zae_+?-&cmh6mK%Y1vHSdAogd6Tw`l(*~_3F{0`Uk+~SdJ!?7aIvf(e^xrJ} z#m?b^_n)?yf8&Ea3t8WrYk02-{H_4=)BQNR`-=#7QVfsy$*>vd(-OKVvtAat^2WXm zE%KIJjT8fTd~pv*?3yG4LX0okk0_SDgl?s&AMakV-y(T?x(5ww=B!z#ow(VF9A3T% zY0TgMHgRG+k&$g5eScHWvj1enxrtb`1<&BY@I43?_%@EfYQKZGX3H)xG9>wmT znN-tz1x+vZF5Zbqjk)Pfq%Q#-zidryU$aFFRmurEKJeH12a8S~Hn@No_Ke=Jt%UOs zc}Q8?waQ6ko#vnI-%(%=F4b`j@9d;i{uCe-ARjs?cdi~vz`ax8ljN{ratj~+=#&st zgo#|JOX3fz0_Au9WAnKJ`0`Kq~BEfqNwZ9N$(>*_H!&JL zKc4>qK?qDq0YDOK6?;)41dAR6D6c#MbIu2g5e%9$DOL&=PEq05q8jbQT{t6?+;W0Z z_Jr_^5?v;eS~|u;NTHlhLh{7QOy8`C1b>d8u&|CGpoIosNmWi_CM4?lHWD0umS+rP zqXNnR9nUDx88NKq@`8-3pc6W4uMw;3S%O|=j~5&JJB0b-BG0=sBSR>jppMlS``2kq z4498+?R&5`JFK4LC1k`YgHES=2Oc=1>b?o~5$oO$9lnxUYLrq=`3}{E&d_U{)bm2P<>ZaL zJe-n?MNq$$S{n~y|F%NIuo}gyOMAycveeTi^VK@yHyVW3M-VUfrm9PA%f0Di>`MJn zbsovh-IS#H`Koi0hML9(=RTgardx)U^@X|X8=i&*(Tn-LQ&-o6d5cVUH|Uapz~x3- znh*)7=5o~B8So%GVf(W_diEEc6Wd3`q95_P9>`f+%Rf=~PS|g?>f-N@g2c3$RB=7V zL*iO0^`;k^s>8$4aM|+MPl<}nP;NdRG%cuZsXNt=S$8Zp^~v~W@T;eYbb2g%GcekF zLd&_Rut%v-#nYo)P4y>E+<%xr04i`t27pGpM^_X`0T>ZrYA@wI<(U&+uy068r8}DF zD?r@cYh3e9eDLZ34A!lorEVTe%e2exzAzd0bLI^+Q{p7h+96;&LO)z2a-kI^u(ofM z;hEl?4J8T;s$6&ZYdJkH?jmv8Jj7!hNH`psO%Sm)0I^Y%B)qkSO!TKbRl9bVT)1*M zoiLzU?z*HKmN5NBNw@nITDKHDwC3P&11Xww262|27I>|8U7(bE0jss+c4Tx!v8b!k zz>p|OC0NQp8?z#ZNLy6N^@cMm=|vy!l8=G;%vv=cbB+1snz>BB?{u{&e~t9zEao%^ zPliwhCJ&qcw4Ko~D6=F=t;(Of%EZ#nW*aJ(tF)UQrB=B;S+41?v^!m6HqXtIjID6E zDb=YhDI!ukGerd6wi|)EyO%A7nUb8(eYriqmJGW(m8@`Y?%hBuAWpf#IKHDdulqo< zDkInx&gp%s_yq6GfutdoFvi6x&6lOFqu>+QCy&1a+@HUEi>{=T@2l1fwWD*9j<+&D>CJ5P@~)j7!LyV+fBS*LM6 z+_zZlpxoKK?GCzrI;2!y3w~@&=feT>F4>4I!>p9i<(0JC_4gdIJMPOVadVs*x9(E7 zXC#;pueyks=g#^(Ic{uPP;Tv}fS*m2n$ydU-ME|Y(7y1SD*#mQCQU@3VRwYNMvHqb z)(s@JcA*Ypy@&;6S~jg*%v_*=zN`oACh3yz(xAv{E=kg5TUVNMj(vgwTc*Qaqj)%#QDn;=<%xdsTN z1hv6`g!N>-;k2>LzAu-=eU)>X8NRdg$=^e-#%Me<=S>(Z1g?)qBlB~j+y66&`4P(8 zwGZg-KDIJ3#2>s%KpY@^(%pANx=o-4FMpfW4=Xg(aAx}oeY8ycAgiq05iwQ|RK3pm zMO>-&ZBk(@HJkZVr}9h={ft2kZz~Q9jfAUdbCumFc2R{|Wv|KnU5c3s@F!xkXR#CN z=vkB2*b#k&;x#XonmRp$wNZUjqGXI6JHnjjK6E))`njFYCEadca@?+?SzLhk7mm{w zASZK47MJrOH7NBA4z9ZDA3S$$}D)T12;6=+cLOS?b@a=`Oxd zVE}|~^}8+wppg>b*vy=lJKHNAVzvQY2iJg~+7yJ3<5snR2SeWcMDoK9*2`4?jNm24 zw`$I)D>Sudt77_aA@dub>iB?B>zNiN!WtYVdc3!CrCU|!Z3y)%a!YKvfIW|rHJ{49 zL=>5m;cS{g)Rf3CqLnZ`#cW8nBX1kz-2Pf@SjDrwgKC&E3Yw{-t}>e`%$u~OxRHuH zn{WF5SC`)lA)WpCrmmH*B`Rg#hcbj-RK#*KJ^NRu#P7Y`MH_7WL?hLf@#`@S<+3Y@OU3F?3uY@MqRC1LM~zVumsas_Lk)Ic zHIn6$;4!F}{Mggj|Hpua3x+)QKxr<~tC{XQKG+!+K9cIAYQg@kCI5u3cdoaeY`V%e>pS`MVTJP1YO2)rMY z8)zK&Wf!Wbv8a%QskTHCepDMRj_)hf6`v0Qgu9e|4>|GSVZ&b`-KXGOaX@Shb4U(f zUPQ)nbEVng(p~l8C1R1?pZL^;V~>vm&5nlh7DWS-qoPOF$Y4U3eWKmt*|RC%^8TZm z((4g-<{C~1I2L%v<+WW;z5(YSiA)(gl|%12|FiQyIR)UHybJ8NGND}VhfBBsLxzOi z+i~I?@3ivA+3%C(U2bcS^H<-m1LhaD)%k1Kw$wO8;E?$_`5CK*464lC;YisoM zX-FnzZiKLJL|euoRybbYBR^#^e5MC1V#2+Jy=+d^CF5l;(WxD?*|G+fjCzuQ^usgA zd#z}$N+l$Pr||ZLmB{Q*-y-_z-2W?aD)#`EfWu<8+FX70Az(gCvoDn_M4(?-;c6fM zPoAn5OLqb$Zq^DF2LK6aDg<18VyfClD3|zqRZ2%lY4~`REfk`}`Rh(vV<$RG4a6+H zC(do+qT$Tb13@ksnT#n`>2FP#NRN;djotaPBTlowOLq(661@^38){64YbSn1c$7E3 zCWk~pLztrjik`JEi5bu9IFc&uIoEbeNt298f18X8W%@5eNtPP4Y45 zscsjWLjI_4-@`%8|9~L>Ef;s9VFHxYfDVCKGXJ}p0}Sjz7V|SRW3krQaU`l5kr*%4 zUE1AqC}(fyylAVf=0xKna&evYF1m-8|6}dL;M*P7W@#^}V3)eG_D5KKv2GDeXb@c5 z33_@UaV0>d#s>ucks5?72;X@jz6v`JB-`5Lp{tBsT&+}BobTsVU$!%~O4^W`{%iHI zjM^Z}1<;pAUV9~=3zN4ZI@%a{#IS^aHH`i9dcpHG1);T;CO|sI2w|2L2pH83hLB>L z6vV#PD{)f7GBY(}1zWkqL?XU?q&0*xLW8T#yOvKtFc-2Kr3qy*y&C~ybq3Mx)zIrh??TQn^^ur2oC4R@-Y9450Mz+rcskZkca#F6glCB5hm( zbq->{7rVkH|8Cm+^D^LKiy6jN)`(BbZI?cU6AO+~_oR(yP2oRs#6K6Bj*nOfX!e7p_O~RDQ?HR38|6-{ zIbjjmm|$m7|CUagwONwixqx5)2l9JB@nawv0u(>UNN=k5WeIHYiR-KjBVryl*2Jr? z4;aY9EX?q7F1*f!JI8X%WLF*4k&rN5P4AQs%}fc$#5p2Y$L!{`G3xp-JbkR(dfyb| z0Wi_xl8-O}ox(SHG7A-THQp(^jMCh>TZihTF<#!1pP2N)>jdlyvPKESyT*&(#^;;x zot?4-og3JN_+u%R!>@ zXciI=-d%7gjs{Snjj;qO*&h@ge#R;n(*fWv_&=1MRm?p|+kbwh=b)?6t?mlx*t6u9 z9Wu;03Yu$;hyChO-S;~2gL3P71Jd#<(7mFxOdF1RomekMe!3^F=Z;G_BA3l)F1K+a z@Sv;yjRHngLS8vDvybi>y#l%9iUQ-T zNhUg_!cRZSpg+ppe~9qs0wv93^q{7jh}idDl6d+E94r-k`D2lhUh7J(Xegc3=ZLPj zwxC=#t>7yI6n6sln%u?dg3lBkNw;+na9D}+R@io$qf5K3?F3_D4OvcIS-&l0Tz~^d zntVmy$$sy`aIM(gX|UdKtq_Ng!{NH(Q*zBxog+c&$w_}B3b=p9XrzuHEdWuPAT6c& zu^plnU8KC_#t^=?|KN(tZJ`%Oo4Wn0Ge6FgavhO%qL{$lP- z^Yl$m>Mn_UmFu?hOpWU~Elb+Y`SAj%+P!Ldv`J*eA zc?^>Ja`+`Ct5;xTL?}1S`uido9|MW~&0@je?a~3U0Iu5&S~0i#0n^avR7y0Xk5l+3{8k|r!m{ib0=-L@w`CqsoZvt9?q-epllC$b# z-b)}|_V(vI$aHK(t7rc$|F|&L8F~*uKsPga%iP#=n4*I;f4C7AEG;(F2M}WI-j|W9MF=6vo<>Xyl7mDc8r*$P8S-f2g$&%#-Zv7iV&hMSvj*n`4)H+6!A_ zm!D;tK;QD$g{e;(53*8SVe2=tv=+EGVKMC`{G*agy9(dZWgn(xshMU=i6^yJ6gTAq zQ)F|bf0QNX(epKNg0?~S1=;FnRQXsPac^1iD4#{|ADP8f!;f9%pv%d^D;v*7n- zKWXtoJlzk`>g);JqWrePH+=sQ0AoIM1Yziz99FQXyW&`?QtEgwT>B21?;ABIPCkn0 zSacalSzuFaypha^R zml+{L7G}(@c2;5sw}NJ{H3cNk&h5x{M=JmXzmpg|j*ZTG}kaOObvwk@*8 zP1EAF7Wk%SY)krf+6S*@4#FxngtzRt_8?ouqlf4c=i!8evYf=`h(bPUaI9PiK1H@Q zjXMG`n-wFcj3Y10i|ZeL&tvlhN(4D0u3`E5(hH;RQbD>*Ki1}<5cv2%R)&vwmknBZ zZJPvilnP>*R{5!#cY3@;ALGoV&0VQL{+=H$Jw~8gUJ*d^ z?I$COrZ31O*-%qs5qX7=D{5Mlt`fptLZhc{oq!K;?@{x=ZZ5fJ2m#05Oif9k_nXp7 zfP{C!4AMi^;2-2l=SN>u``3X1z)fNYR%ds4Wd#i4TSxp#XoAI>uR9+U4V8*nD2xHH zqD1kJEI|&HUUT#TN}N(XU{V0uvd(`Yih48i5j6>xxI!w`xZoZe)6~qi$G9oxPs891u zDALh1EqV#V=$ zG(}6ozc@AhKo_k*ef-^YmAF~RSe!(a``O#+uoE^v z77F|dLXo;7jjLRZYz;KLZg3_-0=^>KVP~VLvOA3cbNhLM1f@zjP!3WoF1Q*?n}Cok zhE*#-;+LwBuqDf3tiUSr&BI2oSvRa`X$_3L@*5T{}S|2YJgk2qWIUT zfRkMJ#w(#D$R*d(XAIS6Yi_D0*M;IkJG39<51nLHM`zw7;bz-4VhQ8*d25G>;X=fg zS=RWrB;R(n@8;pY>JSsWDcr*EED`&f%Fc z#S|p)Vg|*jBs_H4>r{8o0gvV$BbbUB&9E43DIx&{~^8f%4w~&f> zF#gTCApw9&?fg((r;KrSJ9UY9P+z<~h^e`)aSu++i?YR$3MTTin8`y!g);h)gy%hp zPtov`zkC(1t5@8&OPz@+0iD`?!JtlYJ4v2&)67?Xj}i(mUj2}IlQLFQ+$?~Vc94?u z9vmX@#gD%7&N|E7PRy?-JMZ~;!ihgJg}6gi0oKi3sH;t{waSQ~@JO!ek-D5R7I-P$ zt;$;AsxNL(z^A8|)9nK4nh{RUuGM+wCxBit9RasgU6m&SeA z4@L_QHkO;>n=cmkwuBnK?7o6O)h;YDw|k#vW3-bPZ$)arxGQCa(H^(wFPRM&?SUZ& zDGA}??AEP16}8-Po$2M)o`GPGN0!CE1D|ToL9X|AuJ;_ES0@DxsJqRR zGjt7X&Dbp#8i^TCQK<9`Y$LG$+RH2(OcuELYL$_~TZ?%m(sY1KR?c(vX0G|tL4|wb z&6e^{@*oNeq80o@fFIOo*(FOizMN@SRhEav^zfi89$=2BrCKSbRprIzi9C$Wsmppr z9XaDCy9nlL&WvsZ27V!#z@YN2b5+3z8-0c-7bjV|*?Sdc!YK}L07C*t6vw~*M9MCFG? zuld~1krLib1=htI=JyLYCyd#~GNe^}+$=O^%_;TfkfMsEH@rQ+f6P?T{`HaK-*-NV z+T)n(XakH|x1wg;y zv2t}2heMt3q(fTP-8!mBx8=M)C@Ia+8u@rfGAYg3vCo##(&=UuCGBz~2i(-Snh}^C zMTK6`7mN?|VPs53mnIlw5%LzvCf)J=e{2odL<O?YC^4QHIs^F|rB2Exltt3t=VymyuvlLblUt!Zpmo zv=(oz#8WySj}J8vZ#^D?4M=#)q(7cDGUc0HK^-^95czgZCgBBIBKLc>)b?U@RB~tb zIKs|k4;87VKa%~g-FcS16m-b{Hy?{&!AQ@9ed)?bl84pz^~fq_Jq?6wOamF*GB9^{ zIah}h8Q2_uCws%-$dGwo{wQgkd58=?Fak3(J|ap~yyNRW=MLvr^avqxs^`IPUlZ3U zG1QTR?QDAAC^wjQS_CsHb7ysWfWd&vb z(l>W|5b?UlqDjFVGME9}A0JAO%7(1^h+=4%CF>Y*`Sd&KWe3d(2q_vpW42h%EGOmr zuob?CWF&*AFWD6bBQp%C724ao(=R191N~YJ21oi(8^zJRUe}8Htj4QUx0)?WQ!P+J zh7CSoFh^(*L#1_F@iI!eF#PVm?YEBno07rquuiX@GRYuRR#slEPzyMk8m(I1tXY;5 zH{|9T8exi!wR*#s8I3!R{ie3gBXq0IAuYQCfaQWkU?Xc}_mC0I+4^~c-c{)erJk2I zQ0{4h4Gyx|H=Ps{lKH-e!x^?cQu+#~$|D)F!}ZnY_)1!mN?iLDm7#%|r(9AWStxp@ zfgj1|Qqs4Kq$>~8+>yZ#CXB-OUj8qR_Q1fsg**0xaw0d^9 ztm-f`H^*0rNVx?R6RrxHFX$fiaK~`R{MvUg7VMzPRNkHQ&i9t)4R3cQM6Y~PYvLqjXlDMfjNGE@0Nm%&S1!75CQHzEiR#n#${ zEZ9GIv{{lWO$-Xi8;Q0P3gC0`8 zucKZ{$2_!&#;jBx={SJT$;1)AS$8?!P%h=L&NOQ-&0F9h}yzm|Ij*(yS=C zUH@Cli1>Kq?25u=%6x{SB7}LD{gj@*=D5PS_(aT0BXUw~)j1L@%8=A3mY8OaYhs$f z&-w|0n7H=w`OD}crYQ8xNjJ)k9;sBlNq)skipb^o)zOpqdwgGCm(qx~4wDj&$B@&l zAiO!HCr=-J_BXTsqv4j*JW{QWF@cE&O^Qj+X=&^Jzt!QwP?lAsPP)x%j4bQ=&3;XY zEi>!r_QcGfdh&?ng&)~zNOH}3J$adpX7PGmDx$5w3NN_R`3B#H0xUcls4?NqJ0Tgd zj)T*{Mpd22uu0y(j#i_)DJnyfwL2^>=w?xyg}c57}H|(wB>aKcrc2#TZh6kJ6pOoeE$3$)oti@JWW>lm-B@95?B@Hcjj%fm=*-g?v&_i zE+_T`#~#}xF_H3_DDs)`g%{xGv30l^#0+e_BEHA`<@0)2D)r-=-hgRpT!^*-o6S z$xz^W4y_{VevZu<95+p&ANn>JTaYp!r*Bvxuc)6ZFRGZyc!uu1s1$s(zF9y2M2dl~ zZO_bdcWXO9zrw6~i)<-ug?I2lD}s-{`1g5EYvLFT+_!KaSbvMlzcp{m88s7rD6C!3cMk{SDcS)3EDf%k)f=tASy(8|VhSyTgw6 z?e^X8{(~-tF;mC5Wz3auyPbF+OD?M%+mFH%)#kAkRbD#T=PW zm9FCAJ<`(5E3H5uLQ!5Gu1)R3-CS<1N(o|t#n$4UQ>hBV7^>*C3Dz>$sIb1_FHE&%^ zCIpn--X#Gqy3`)rxykM?JC?%f{D&ZB|J)-v7sHSKQH z$ccB9_G*LsU-qx>7x}iAZ-!GONy_BL0ZIW6ZtVZF(6X??!op$v7kPX$Ma7mguWBwx zxto967wMU8)4Q;TfU-qwNHSH6AzmrI&tR+PWOP!@*qI@6iu{8(6@jt&S7D$0^eeBc zY)Ql0-cY-=Gq%b#W&WnUCE1cWknKP_&$yZU%AR>zz4JEpgxTdvViD8pf*Q9Y3;mM0 zi=L_({#DaU3Iwf&>j=A{YSD3v8aG^#1K#JId}#Z%>$?5TkW_z%c?g}zf!RWtMG}_Z ze#;}%8oqJ^g7J$LP02n3uChfGOwkyjKA3)auin`KuI$JtiBDR9gv_eJayR_m&Tpaq zDLG8j2!-l3#f^;NCV(p3J{aR0mtTzvVhYvOuK|MY5W z8RWJb0DMKy<-0Gr#Es6oyj_Q*N0ZNzLYAL!#^z_g;us8CMt2t5mJIe0kGmsKFbr(H z@V^Q3+ojZ5LD-or(_f+LV^IVo28BN2{WH7YAr3CeouJ^SdI6qP@Jra#v{D0uth^o# zN?~qFJM+<#$nLnlW77QHut+LqeVWV`40zTKJ(-ufTCG_sTA=$pZ?yWE^%UDfS64e8 zN4bqwT6S0{pGRdr--T}>W(8M!h~nwfS1e-Y+ZVv8?RK{ z+=zL0>L!~RC9r{Tou<~&XNMm%>MmlXdJe-^`@e6v;Ogq?OpRr;9jX1^tfQfTiTieF z4(m_b(DIcMaEt=ZXHUi?fI%Dav%F-QAx~bsGk85n9N;)6_Km`lk)JJmpUS3*RIIT_ z5@V_QsUp`3;M9kTPulh$zEA#+_Gog$MlV#ppZSo>R5b8WE1z-}rfskpt_l1Det%s| z6Z;WVQp4yJ{e=yi_=I>q3UVs?J5KZj1_ZVLb|e3BLhkO3F)WY$4uD`#k0nY&OPimU zSBu5E;K^kt{`-dc0oDoSVG*G2aH(J@mm@5xv(~P!*a%0+DIvlvL`kzR)K;GIg0Uua za9AbFc{`8tPk9aV?D_+!mYKb2sNP!jRC=b^gz3_E1)zd8#savExtwKXgx|@2;(^k8 zc^?$_dZtzWSd++om^ttNMOROrm-o0X&1b6T0%J&=cgob6cX87cYkuWL^poykNa
=zRTD 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
tc-0h@`H|S3Z)K^|=Ai7dGUzrMEdOyLJ}z** z%`Azsy6-Ntaod`$JGzZ15%Bgrh}2!KHtf4s@H+1ZB5!p$NPJJsvvjrgiRrk>_HMx= zsYJc%mi6y(Pf_@>M%7knjogV&-KCA=a@&t(zwMiE#m(Mbg$O*QUS=1`SDG_6OI|05 z3LncorF!VSoHnW38r}NJtmp46Q^@Bl&TtmVmm2k#?}36x^;_6>rg^Vta(?|d{n&C? zwaBvQbE;75Zhv~l$$ehN(fu78`tx~vf#2@_C~uAKfx;!H!%#>jJQPh@KQ25L_2i#o zaQ_eF_uB^q5vUN6Mjy^Gkv4^oyRzxa?`o-uR>YnKVbK2IZ9L3T>(aSIk-|4v9_DW=wT#@!z?#YahL^+CKIJ@&9)0@H*) zZqyA8gNA5?X+N1T^?!AzK^MaZ>Y3abbfstp8t@I)%3GRFH3-b(8Sumk&8wyqaM8(r z0QZMJ8r1Fa2HIaF88Pljk^H%};*fq9X5;k%hU)6){0xrnjNf8rZQ`%3?I4fm*?3PD zSYi`kvWLM!MhN&JoXLLnM(k%yP{?qcIGhSi>DID%K*Edhagbhkun5BBy-3Yr{i8w;=PwKb)PTcO;@I4I|Xcr~fT^tGs#BEa@! z>gY5cUEJ{b^k@g%HK4dF1FB$O=*4$6wFk1+D=uv|4pVc|`>!1=kiwcZ0j5Q{U2CT^lQ}hZ-6S!_aCXN@jt6@oW`eA}&-mH(@A4 zsLX2o{HvU5v5xcXLS>W0;B2wF*G+Pc%W}(B>9pLpQY~ejRy()(M_RcZRt-Uq4qtVL z-GmW{kmnANl3@BaDIS!DJ`Cm@o}Du13eT%{?Hw|wMf`~=m}fMzq-=KG3;|l+x(nM?(9IY8>e*PCdwvM~p)xHCYs5$yr-q7zPwrw~gC%P`CE5hjqqgA6(&Q-aHe>V zYj-%c%5|mloYpmuRkvAh{nC7L{`9`qi_3Onjht$kPIaNxN+l4eF#`FRS6y#(zqO}=k_TdCNR3yG9_3(e-|{Y= zyNF)<4j}j@e=gF3D`H2b4NOfY(QE&FPLJbd^qv4(vPpUcHkNdL@7~88hs08~VBF{B zo5ao-$|QGH`IENoFS&wpKRvuG<@Xg@1mVMv@A<6dr2` zMb6B!2}@ElQW?Z{GQ+E6f94Uq16j>=!QgVOd~xQ6VAn)^dN;b$1&+=>w?06!1oUms z_qDShj5P}!JaYRlV^A8nsVC_nyig>23dzou)eEI&Cec35c$)N5M1ye{Tgjta3jiat zjBz{K`A6gj!~~T;gVS9_(pKlOnuZ23rUcR~92-6vRk&1>k-9?{n}3Xr`q|81<7P#7XU9($^rNq&h=jD3-+maUZ)M!P#>JDqNxUk8-~2 zKAqpYQ{%~ZRs}BxC_c17sB1Dc7{@fTzbO^;o#t&o}RR|wbO1V zmzNi3cj@eI31U9YQLRg(|29$4Bgo{mR>>3viJlFqzST!}pfTax_%)YXBGjy_mx|9% zGr-_Rl@6hA>8+3e#p6njS+g{Pu?e_fPmKjlt_z93LT|>l$DTuUr$?8f2BidA(F0P2 zbn;yrBFhZ-%;SGz{ylOyD0S+?xz9iq`^5IzHzSvi2CxgxfU$%9oK9#5y@Vva+!)ZTY6@ws&~8AD3r2@QODk&9SezJNSe!j*EQO>F;J%m~N+)!YhA0Br5`ct0 z9nqC8gp?m^#sAC%sm@J+hm>sKA@6`Ly5Z#HM9PI3kBK=IXlOt9)3o@VP)R#^xMn1@ zU2pB;<0>$w^Z#}C#f?ud#34W)0^v8EV;uN<|zKg+kZ2)-JcrAGL zRXc~aEJkVCujLMV>|eGYLHrxe8`Wg@ORgI~--(|o6cqdzv8X{m?4kv;KZsii4juBH zV@YPKxj%R$FD6@-%>eMX536>GwN=Z)%=F4h@5T9fo;>8E_l&RcMN+9+wZU|30ux?h z(y(T$>-EMR?S+Yaiu#9|?0U2P_|AaPQIe1}Yev(&M*dWJdHntU~>+XlK>bklpZ=DJZXKZ@Hr;pKp8^c*qHc>E+~- z7KkKDInFy4^H_{6>ROCXX8y#1^8Bq}WkWNXFyR_+V#ggylHg!|;}7Pyn~%iCOs)e) z4yN};t--Ul3kBBJ?hzp}l#?S$cU^uf@wt#rFRTG(*HPI&%^vGQaAg)#n5g|%S|Q)% zBhJLp`_5xB*cjMXan`ou7BfEb9#+7Yvy+Pi(U)@xXEc-k8#F)S+5wMLR3oszu5W?WxlJpP zUa?*NK6|A7O;rW3?gXWCn(vyPbPS#ctgavnn5H^2_*8&IgiVDLGt!S=pE45IsecwZ z1hcQ|P2Kg=sHM&Oc=vh%$)B`E9sU~UO9t#5uoxDJ241{gS#=G+x3#pVD+)LJfF8U^ zcy-Rbgl(LBFibv8S+L1yq!MoMgi%+u;=Ti|h}>Oo`^hq~Sm&end^4d#C`#UzfSdFN z1^x>D$wAo7_0^3Gn!q@@3r_Sw11nWHVn7JEoRbx?x1EzCGQx+2if#ZGP&oE1yLyxC zpGYMH8^#dy(0yMI?<7Q#!!HH5ZL9!}5#NNrRP1!rFwFvJ3QwNnx3l;HH#axdhLWR_ zyux=;U&sVh9CD8}maifY&Rl_D@!>}`XkVe9m?GFiwI&9-R-vmjwadA=Rz2`W2O+oi z2sx(VRz_-4JJHNq)o#?aX)dQRpCT?y#p-QoZBGMa-K*4jhUn8+D5m>Bm>hRmK4BqT z3FJwiml`Kq)WpOq5h1=)xeZe91WLL)LT`gi4VYIm{NZLsOTZ>R?T1Rv}>2E)N z+L-tT%ZQ80Si;*hbI{2f>NC?AR-x*vfKp2^4I_-w-0iD>8L{DY7ZFvm4(;-BAnZ9C zWS>N6;YMkp#_2eZ$(acAxpqoqWnjB*(oyjg{Wd}18SH_13rnDHBre`9X%v>`qnsA6 z!%nmOzME&{eYZ3kF((uni7jmEnKK0-;srbnO@>*XRbNIGydI>=9%o)QM>G=R=dW!Ga++8c3)N@Xen$<_;1qhp8K}=MzW#fW!RVe0Yj_FnHA@jCwEl^m$t=| z4Psw6P5F8oRa+NJj{^Yi&pluP475)5q(92)c#(z1^`*w{aTP&2Xx?Ly7y-<=#BZ_` zVj{yzVien9Zt;gQb(yeqd{A5RG29Z*&)yH$T+-`p_cyxV7=0Ec0e)xmX>?Uz7$#c} z(sNIKANeZm{iY4jR=+U?+M*Pm9KGYAEVwHAe*sazj^VG`KkvO9afp>MRY?mo4DFI_ zu!QvKjPE=O=F$3dQ+#^_rXFtWBmD7DH*-c$LKE1m{JDY%$r$% zJk%U|?w+AYL|9uF4HjgK*TM9K$cJA_v0{e*xty~Os5vwK;@qazAGd97CmH=+&kGho zsZx6$UGko{g3pw4>o-qk-0Gl`0;vlq51r_pGPB59ez0w>5r8AS5d?L2@UWnj4$MA|Nej{_%5 zI7ov)EiMwTJahmr^t?3 zV7#C0D{~9)llqi1=!DNU%cE_3e0&tygtXSho0i^sY*{ipGW{(BaOt*^pRUQxU0L}^ zzk`eSRz6LzrrykAJUa{Q$E%+x#=f--meH{9_$3Rl*g8vB8|k|rx&z$cxI#^t!TjFykK0W6 zCCs{NwMNGq{6Lx?zWnEU;Mgy2TP7V49T01l);iVR&~JbzDrBe){xJT}*qtB>_RwQb zk3KHr!>Qm)OVa6zV8O3nMhlk{6R{fwDu$7hlZze=>#|?vot%yWlJCQ6(44MR%huAa z#k7a#0pvk7gWqBOhx}8|0jc$npyz(|JZ+V%X*`|+BkMcn&>jOik1$(1U(Owp*Ce-@bX?G@S{TQ3*Y-ye|BwhX*Kka8N@2 zud7YJ_NlB#2p8RYnW=i+;!FVU@8?gB-+1<$-@-=Q#^^?^&QH!yj8BYBjm`b2X({TG zuUA3N8%p7~oo_ZC+-21i^cX+fs|pRv{T#a8$4B?je?|2k3GWM41I-NG_$15X=b0fH zV!UKv3FyfIXjM*a=MapD)79B?YUQXij2g9G{j8Z8QaOz8Q-RB3msO)xZ+@cz00`3e zIj*j5oE(8IOr4gcwS~FmUepcF_gV?Y7Q056qp_eT(bKJO1StYrwU=tn{zqcwOr1Di zHBR0P>nUF#TEXCK6JQ?s91Wrlb5PZJh!n0%mWv-Vf-tB~_oEkDQ$g15;Q^6b%Ptr% zCMIV=^DTE#lnd)OyRDRbN%k0j#trFZos(-#WhV$Va(NrJ%_eV>$F{g)5HGZPPND7* zcw>|$8nYqIR#KH|COIy7b(5WdyMF`Po!F23A<^8yU!7RP>kMw^L#NpYFmVw+%wfpu zjGS`l+{`7T=sd*!gw6)`FPU&UWJWJ^=;j`%hEb51S~eC&h>*ifI3X7@GV(pn*-qjP z(AxjGNa!0)<@37k4sjS8pibzEOyk#4_q^WNmYYpZarf{*IltH)Nv^V6@#W{*#Z_QU zkJnu*_cPSD1>^Z*{b^6YOAIQnmyV=n*{k;nlkmqJ=1gf_JN3WQ3Q?B+B9 z=G*`Jp-KJ}k#f0C70ONLp%oTZc=3hB{?{gkAoZstFaX*WA>nTE_o!692<9BPr!9wQ zwm_wMZ(!-kG}-kwK(x+*GfOH&DsRmK?Ei;nQy$zsF4CGIld?&}y6&?Z|MVG(oVLAN z=5Q5)=Yq5HG||&HitNNDMh3xxl5sRq1|C*S>Nu2dvVOIj+#0bTV`dt6=SCMOsSBe9 z2V)M5Ti>n?jzmH@cHm7HDSJFRT6UYEqYgOstPkp+cTugc)ANg({#GWXMRk^($_&krC zv?eL{Dqa3Q_5sOCj7D=d93b4!bw0J>2N+iNAOJzuVaJRLQ})OoM)vBi1d2CEG@2aZ zKFEV!0qO*+7zT~~;n6PD2Meh*_(pgWR4qb=8SXSHC94zJE~cET6P6=CCks)X%1-c= zBQ%?mA%z(o@^QU0oS^j2j_43mOrEq&o~H`N>?dqGH+)J#Ik_I z!6*8UE8d6gJ~#*%B*OJ4b3W90QE7nb(z?YbRTotqot5IfoI!jRHeIb+yAfY=mAviU zx9nBK%W=g40pa0ktuG|S5VdaQp|z#Az-OVnhRMN^cUzMQ+l%D0Xpj1&y0_@7j{pBW zb$NCYs68w5v;?L|nR^Q3B_&H7DJ%#>! ztF)y864q>`{q2X-d*4lv^!il^MAK4j!kC#iT|s+?>Ps(n=r=F_bXw6m8a4XHtsq-V zR2s*9_)dS(=1;g-)Jd&;8;>xd-$y3ljX+zxP0T=3_zi!;O=S%8I)Wb&6gu(u+p!#e zq;VUytS`6bQ4?d*A(GTi_;yBr$ogg=E%QRoh)d%#nZ#;E0TAV=!h^BS8M3Cnn1D1mvYoP{Ugp zn#4e8u^B$TY#XKv+*7w$0Lo)&yv>>lYl@| zx^CAp%_=^- z0yk)!R(*(QOzaAe_Qs`PK?tBjVPqC1c9H#fTP`rnURK1d>+W!>J|XohV*67jM3W~p zc2^&pBtPyWC++MMmb1^H}O#VH z4`j`?Le~ZyrVH^K0K2F}?OUtKcJf!idvz#PTT$_+%Svfvt*Zw`nwlmMsId z2$MJKfq?**%EuA<)avBqGSe3T@9 znJHhlWncAeXLw6Ze{BmI;*n#&3W^Ng=sYxtZel((3L{AQSURPEoS{JvHdc$0ZROvjb#boy# zVqn;B{i<{=cA;AXl9YH}bJ=yHe6xw{N44}_x2KTjmDZ08G19Jd>Vq@_`YHgE0}d{| zed?dEK)pvyf}NBUe-Bq%;~@HN{CE(Xdb8fy!dI77@0*sJVx6W!tDY=46jM+}ECO;nu6;aCH0pkI!~%P1l~B4ql1I`3YLnm)iq|F;@Uycs zowzoHfM4;e*N^RWA#?$IilL-F8l9B);uH5a{C~_fm+q?%=gdKTHU;021dH@z8(f#T!71pI}h=MyWPFfh3{rQ z*Tha(Ain~KC^OMjW=U5VuJs?r4m_2;ucYqM6RU_QuiaD{WcLnxW(h_*X0&Rsfi z9td`P7;h*>{GH$i0VnE-?($0b`FBON)A9ie<*&GZv_L@qU!w1q5Sc`Z zfMxQi8~S_Tygr50TVl`oI|dlhTXBBFrlpb4ieV15mJRx@L9H60=QvLU-yQzdE-87S z%>1VT{BLYZC0K~l5go0*ZHm`Byk+d*JAxi+V4~A)F3T&+E2?YCE8``LYU-a(;Xvd( z2Sp6`FE1&Inq>tR`?xRllg-bpXHFz%3UkAwAyNm~s5O6znw6$yXZIoYl0ZA#7V0kd z;pH!MXW=M-5OnT<5jo!;V**$={S`?N3cbA8j@NPVy!1*E3yWr}(Os4sid=3IE-voY z_BQ1wVUL~Eo(sGr7L8h~j)?}YT~LZfI69{s5Tv0l$|}p2SX(lVPgMRDxGI5I^5oh& z1qsXl8}(lIy5B#q#VA;jH@03+zidaY1s|V&K3b0?`2mZ+T<4;|x48e_5|332muE7( z>?Kb)6nZwwG4HIwdibx3>)8dH|AE&*$||Dw{zk;Cj^}30iNCgCXMRyh1evv>rmj!j zQN_O4>n-g=O!qTBI5=cG<4eoRb8Bh(F~C2)PA?x?Ucvlh{KinA@@P@ ze1ooult`Z{Lt3ndtpm%207LlB_?LJXXfsH{Hk>1~6WvwM(1ZZf3Ng^NT)#LX&?%G7 zz+tLYT-U?J!$H%*!BS3BN&x-yP~>K`Q0buv>mTKb#|fhiW*>7_0j_JqEf;kCRC2-< zJCc5o!LfeO`!%_b&^{Ex4HRUH))qK0e0yT_jf#fhm+N#!frMG?Dpnya_sAheahfT7ck4JR^~X%rTV%Hh-oS{gHXFrZc^^z53$RdZBZrk;;be zpm<jUdZx;sWYzMg*=wl{g({csQN2_Sz0`YUin^0caeD&PFCL_#we6$+do! zNAitSloLbszE38OMoy?an!OYdomA&v^yIS&i+oC$pS<*U)R@0T>#`f4PXYJ<{)kS! z*>NX-)h>vMHFCQrxS+n?)p@TNxQd5U-g|!=NM)2z0X9-|G8yz&@9E+elfY3ke2Vi) zgs4pwqUmoNhh4)tFFf*lOc4;Y{|2b|ALnOxVbG@fzdCEP^Ff)OVnz_4Lq+_*LGks* zNQ2!!un79yC}1BtMY;kJT8iFF(Kz9TXHOB4gMGO@W7I$(Q&L%2@; z*AaO5Kornk&ak{D_FzMf$CScZ@{@zZhCq3=D!{1)%o6%#KNE}WGe6-SpT z6*vG$``|b-5wR5Kc8PN{sC%LeCq?t0+BMxS#MIsdffCf2_QY|}rU-qBWxxMM3t$DA zS*MBvGc5`8JoiUH2`iTg6(%eW4GG~)81D0*3+8i?kyLg^ib`D3UsOV#F|^ETKk%nROGgjvVKG=Q{lf%oy^~M-?l)5r7xlXoKgcjoj)`XQ z96r}~cb~rNT$k(`ncgh1@;yp)64azssy0`n)ZA^zhvKmzT=!4(_3PiWkn$MJWtcd> zXEUBCS~VGI=1RSz`yR`}h<1Q%q0l)-UOiB|jwCTa^|3)Zs~?w6oi{&ZKsFVZQK!3> z4#xmm#fqD96AFGuke-LJt-LKtAT}KCS(&wHEGw!k+pSwXOkby8w~A+kbsXLXL~px*N;|Vuqc3usPsB56Hv{Z1NiyymE@lx~{M}=(|HE3M?$14#Lyv$*p z7t%OHlf%Npj^wTAMsz@kak|C&mNET6!U0#W;5x@@tws%x!kx=? zuD;LTzUrJ<$wkW{a;vjf_E?c7B&7~J8_lE(Pc4CwsOUP;vNCZ)K8Fo%$cFV>EpUu~ z;G%0nKo~yk-ycF8>_sT^qZm1)Pgi|f4=x}5B%W;+_VL2_9T%`ynpPj5moY<}TQ z$3(^*E#(Tc-_X9%!ITYMpuIAn%+H=Hux7Eor4(y}_+*LkMU3(% z4T2F!g<}9AbN&__qSWt%L#llQXl(#O_UKfqkjI|Z^!9#Zk<)CcuH}ghAoXk5qB`=#h*gU`fZBwXa;qoC|!`S?+4?4e@l4 zWIhK6%^vLwS5AMssM#+`?_|uBcj_$DTnn1l;?Jxxm%hsSzXp4=r%!{#RLwA!%gYr% z!Gw#kaf`G|JL$hQ3$6-~LXQA0oS5?BFcYW9etSO1Wh zQWN{X#OGu?|1y3XQB5|bWNJqLl-YxdgD)48GhThh-W?PM1WHf&Z>;JWVHw4jkO5w@wB89aBr>bi%r z=)Z!eQUUYqEdCG##I7x+c|y!6A56N6bTd@<5*&DF%s|>=`Vz?#;D$qNAxNJ|s0m?* zT}0A1jTmq=NXW$KNat|KADS;l(0A4l;7Xn$FIlg?5Kk*F!Fdl2-1LupxwL*?Ibi~~ z32%ky4-_D%o-lk8Uz`mI(3utM?|>KSVuz(b4XR16CThWh4$N~h=HX!3;2`!#8XqF^ zEBIB->RLSUSQ`SgFnIwnPA0?!SNW8`h?SrXV}1_X(YT~9NbdO4+WI1g@_!x@b$|6^ zn6f{*4B!g-?EPbHt0QWZjNkLj5ino?6nM7HvrgJH2c&%i4(At&`lv+b-<^ZzDPs=> zEY72w>taz+JbF(!GF{7iWK4KLh+UO`5mU*M=ZN>SY3AhM{pC?z=6nny?-JfrJc8p+ z_&Dh3j~hu_(&Ufi@rl_;g}kj^24Ef(Ncfc9iW^HCb4qfA$4>wPfSkTbT;86uSeo6gAptadmGC&>tjle=y*=AvP?U*)HACg+i-s79C#s)&#LhPfFYh=#{Qx z*@)(LE}grSxv<1VeTMy$sKr54MD?zJuhm0obB&{mSs=e8zkJ~osg1u>$FNXeK7?YE zlXec1zF_bjz@$B&MQ*u;MkbTLjg=NFm($-=oazNRqjAk#OGmIf-dP3*I6dMqGqqXs z;Md5PrA?EY3(73QBYYO-oNFs4)3OQ^>F5E?ch854E1L*nb(y~-@TZdZ^;7ES2FC_K z#Er<{2pRMvX1|J4I(UFUP+h%$z7OkvSN@)bfhRcOgOd36P-Mnu;e(zC!Qr6mdJG(3 zI>aj?TH!%^fh>Z?F`$&abLpEtCJ`*cE#rd}^0om)BNOolpAsZUvI0RwizVhjZsox2 z;Ec7!G9yR{>B%l%3-*46-yH?IZj!vF$ zrN;ZUt_$Mmbsrxu2n_l@p3T{!3*r?pciX!D{V2XbsfIO-FZ?356WZ^izoS^zc1>yp zqd#ZIr{ykOK{}bmRCEaS`%L#?|HT)A#cT-Uu6x(A>O(J&_yBa7*VGwvWaxc1Cpry+ zsgx{UV>_)a9$jEG-h1NnilV23=-Uo&>B2FpHAy8P8|l{-pcB zSRR1$hvsKM>of4*Ow@91>U(axcoS={qh>VvU=Ru zdNQ6|&(?1lx$T?W8_TqslpXFS?iLEGYD`CG)7r)qRa{D1D{?9tTDIM#G)-Mx6m)I9 z0RtE9(F9cZ7#cn>$k~r1h&0!cf2cQPA(%gyd`wRgyg51)4v90MlE!A6lNL<>dvMU# z`=PN>Ltp_QOugrRKgBbXURL(O;p`9+wp(by2K-!8U3YnXll+K^RS_-)V~(HF-(e(5 zB&jS^zWrk&f`Nz)PO3`E2TEPQUc^Hum1LK<(hfw5)Ws*BMFt8!H+I9oV|yxydG?Eq z)bw6YbtM&H54SVXtB(ozen?E1=z&V-pY>!uY&MHHM_APrgR3K`SRQ_hRwfPS=t>HQ zHG!NQ{JAmiTe9X#tP$)|G#U!pBC6#<-TQByBhWFXI-h~8Y*#w;p=tr&AuM*8F=|%x z4W~Cs?BCY{yQ2IN&ebfoOS6+V3{zd2^&iX@Ga?Ik21o&8Vxos`>%dGUx)4Mot1 z^rYm#TDd(#G@KDeJ-zW7&Y^?VH2}5;4#+9s zf(eW1Eh7k{lNU0Q&8iJ!1cpe?uU;;tAZI*QFRemqX9wPoQniqx=NVlu7=3f%-ca!M zvde_`$eV_0;AH;Tf$D%gGmkSW4Nj$sbpT)p1V{`RN_#alJCQ5M)IE9^B`=xBnutuw zWGKR|e7fv$<>colN515K*;ak88OnX~tO#a09WvYIYm53_I|?xB_APi_;blwM1RZ0y zqPOn(oY-8S;QYweYf}@RO^3_8g#hkZJ%d80bnc?A?GFFSsRl7@|FR&?bzAW6)|QHH zzwUar^&SCI^!Vt9Q7${tBK8+Qi zxi@zZ?XQum!Y;vwVS&ZEp)f-E1nhqn_C7JxKZlUSrAyX!?3N;2uAHb_w7pAO3rqK> z0w$$K$b^*LZvR4l(-(eDFA?&tINL~=2~3PUNNdp+I%};D6U2Mg$D6+w>>CIZWp+xZ z&hV6){6HGX?}bI3XmzjCw>~CIgpCIZgGhkC?YN{8z`J&zi8lj6n%{Z93rghHl~YU;1A*C&IP++C?-@+{bQi|T%GNH;9ce=;~MhzjegKfCA>upiNXlAOZBJ8 zYU+fr&kTHJX(6YbXC?8(i<55W{OUtyD&<2wLGNM+k{I{cla4s)-Wfe}&SKHumy=o{ z)4#vQd!z6cx`y|$<2M>>mm-Qs8XMo+licY`NaWy5ohKD)E_&*o;f94?RHHGKP z(JsT9nwpm9{UZGf&DBRqv##4XVL3@a7(^u%Ktul6;IGq-8r^qa*LEY&;2t9&p7`Oz z#p@vgAytndtgqzX^O0IU-Fn$^@qXA9e$e|lz`=#R%X_+n0Onc~(s;Ev><^GVWv0n zdgl5#^0;;7*;O!|VwQHaN9$H<0sM)Bj%LG;Pl$|o{|ybcjjrIz^6KKuve(gl&7gdeC6UEDwy$IYE{}HGF|V|u41w?0o%2ky zrPVGqH%(8eeQs{jcLGCT|7?o){*Xs2tXzNE2GV-{kTGHg=|=+l+*m>?)xTYb(W^M_ z#)~p_t0`B33=Ed|cU8ZH;}601_{667-&vQGKFx@b@+U19mB?e<`}=BnY1+`WgDl^D zU-ymp@Q&`Mvtk^9l_i~PINxtreQ|8iP_$OPXsQy3{yk5B5iNz#;Z~ zq(ZnE-WvWu{hj^Ooxa&a;!<`1O6<3-&M#CMC6`_P2$Z#fG4DdKqD1uQn0^6-8Y^;2 zEDo7#()SR`{2n@Wrzs8+}{O)+|AR zo##s|&tVl+k`tkaBwBVgFTI*b3=J|RNHCWMH?UK>M=-v%l4d>(7pig~fj*!6L>erE zzA@WtqzNs|5whhg@i!i&PD_lJQn|YuGiJ#*j4{WRj{+x-&F>9sCcU;nsKOyPQLYT# zn=1+f6?<6yb1R|sTs@KwpHM%(UQ9+(KmrAd%G zfJ)@d9d_3rnFb?{Uc}UGKn@#8NQ%x(iC{(6r=UqxM$VTx`tdHFG#JNo`BBVcQ9Ocx zWc6e5Uq8vYJ(YnB2@xZPW*&;g?&xZ5b>V%xi@CR*YaxF=!iD$vRVlD6JS@*{0}zIL z>(#KWKT~JO{PV2vBZjL{zJ8Zs0KVjK8-RH6BU5%}%sZ11mL2l8VKlIof| zeXa)FR&9zG&v#duhT5r25nR`&CvEo#>kQn#JL7zd5fl*e%AGZYl-Z(&@~kq~L)ON{ z@jY+S$mf$T_opqH@BUr@-Ab}kR&Ol_W__pMFC==t$HNS-yO9U)n;PJ)k5{y-43$dHejbp=%-Zw>xDDdAF5WJtx~5VJ zQXZ;6;9>u_q=v-_wJ+5scHpf6_1mAg(S%r5 zBd92X&uJkgw7i5qC{u<*lKt~lU0axP!dM&~=Mu_lgW3_5!B!~WD=R9t3fRA<~nmTY_b~xPbt+b zx^o#a`xR^qVYOuYVw|)>Z6H}K8s)@b39UCn6AwDe)7oEKzNtFRPSn^!2w#X}zD`dI z3Bmk??T3otu$4^b`@Yc~f)g{%b>%!gW*}X!(t?@kfrc-v!P~N0ZXkVAb$52pDhThw zc*S>l?|WAcABu~mg)0Vy2}sJkesXE!4@-~Yh43QfQk1FJ(?&w-$74mriEBj0ktC}9 zErWF!DB~5CE(y&6J5~359mE{yF-+W6A`%K|xV7nAiiG834eFE4Pj^pOTBcTPr_Mge zWfs*#@3&MG#ReD=k%cC+#k#)wtGdUrHP89OQ!oS&Q^t!i`vlw5S(SlrZYAq_7O*RK z&c9xeCwx0`qucBFCKp~w+lF1pscGtQvMQBTOd?epcP6+3Ty5l5_lv$f`{mI4QK4Ka zT0!_i6cJn(PageymHA;gcp|z9PVIA&=Ve=W=Zf_mwOMa(Ukn~Irrak_Ru;Cm7k7tJ zA49i=uZ9o?^IR|Q(HNkZ6=x0aw)iA$qZ928d)1gnjnH#c`ET)~9 zrAd*Akj>Nuo>`dj`2ChrCYm1m8>*7|oxN>U$gLu^*(h=KQpblAW24ygCo@7rYFb5l zCF<#@RI}3!hVxdRUzof_ou#E6#AL6^#Ge`)YQI96+3|5UD4-kcn$(n1MaxT1J6GSo z`*rS0CCXa}U0~gl%Agm%biPFR)N^c;9mNNu+Fe_lX9 zkIUB@e^Ho*6$|CcLS;PsHR4Tf4aj@S?0($ozRT^-&d$C&H0Z`4-G0*KesWzWxxKt(Bru5yI4S5_-bl?C$Hci>k}n?fsXk0J78H z4Y|78bMAso=R#tLK7xG56pnhb8yh5}^Dk%?Q1!C7m>!VBo9j+9O{C&I+ys*Smg&2OBaTn={5 z#sXjNmN@-y=3*z-d=99};UQx0x3_zF4ud?tAN>M{2RNXoX zi(4|CqK_wQl*Qd-bM34=Jp{hkds z5eU2TC)Rs=W>Pa8YwI?d#4Oz*q|W6sjg#ni9vR5k3~@+Ybm^x0z#Wk`H1{kgEH zZzMpk;oKJLix!sRFj?AJIfhjFT=fr)!|yXTV*2QF*WZ?eBj~CyOQEs+25D#~RhlZj z#lw`HH8gU4-KJ7I!J$qzu<0jDZE`_vu7l)ZJQ^%s}23WwkFMK5oFVpr6wmsV5U zD|&lk8+N*V(lvM%x}til<;>(sgpK2EGsN6`YH$baX$*rq7Dr)*ioU7V`EbWX8G1jV^lat9F~hDqbEEr`Um|<9Bz)i?u)2m5%87 zj_2p|DpjSp49%-phUR(O%?{fVgS2-lUP+9`PZ_Po^_T37J&ba8!Tjxp>%#UfY{Pfe z5X`{c_<*O}aCdh8f&{aBn4;=EPCZjf7zrT^)Nk z5Eu#aL>HoDxQoZwNfaK~{JBZ!Z104}8GyDv7I3(+_TQgGvL5SeP0hW3*R;om3(58q zaTg4^dC5daf;vP)6KzB`%D3MRG)bGMBAu6}6|Dkby=426qkB>1D3TEsS-Md=dES!@ z;jHUET3WjP=!g45{K51lc(9WbAPse2zoPVQ4iFWMgr3$wyaemokJgq%|12RRR3OZw zUVlpXMOifsCg0CdERx$1QQ6N{M z7;IU{>9UVWK}Dr#S+B?XjTysDwI0EHQ?_5KGU-|d`x}RJ8983LU8^xiwu*wK_O@gg z8+Zd}hDGS70B`{tJMt@|<(HAc@)@B{?l{bFS$}UKiueI=G*Se9h%GY)9&-JL zzJ^q}+_YZC@b>1p95aEi1jc>9b6>zq&uNcDo!9!(jWl9_5Wzr|)@>Cuh8d+7mxXOI zhEGP{kbvGc%rwf7Z$mW#Wm3OZzmE|5?Pol0q$W_ukZ=c?2bigLp2ppDe!--zM<9(L zN5E}E%1~_!-i6$EzkE(ZPSV+n+NL{@|urY`} zPZcvqY8V3FLNQ#+5;QRoqw=BD($a86*c@LK#U)`?r_s2@AY%06$ky!D4%tvp&HT}I z!^^(({Qi~RV)CvmiL~Ahl-$<1)(D@+vjWzSb#!FYwfz!eQyiDUkr3GFMSplY^BfM> zyQXKi?Z@S@7DwWimRmaxvpW|yFpAi(iTAGdV&8W93VnAUU#?tE`6Q?ZO9}pli(c4R z*Jdz`&a*%*S{Y2eC9lX|F4*AVR2d>lD5hEIh$3mjKl+Rth$2Qgu_7HJcz*$>M&D}# zUSFiXjQeXbKv%ef(Wit{F32PiFahm4fPI3X88#OgADZGHd z(S-1lDqGsrU(9cj!^ktBq`uK3w+7$kJj-KrzGi93u=GYEv#{fO)ya`fgbeWM&uINc z;n?@gOBGHI!?DF=Zkv9~aEeDfGg-AC&Wflj_3K3TW| zt0iGno0WgVW1~R93$#ML+i0}_m6&8*(@EB4>U031)$C4CITm=MqS+%I3%%ciqHNJ; z^1k0v+L)HfnvA`cqO2eV96O(0@!bU((ZdzCVi89K<;Thg6tLmw|JE6vbzNWBI_F?# zSJ#{v|26*YYYkk1zd{W)Kjl~N1A_$1rN)4`Ej_5aAw*+5=IP$DLjQ)n@W;zE(|p^O__O12+c$``~iy4yhg@DvQa#-VE8aQo)`)!DI4%rx-~ z#+5p3{^6%AIx?E%FhMPGgH;9teom|LGdtK|ywVfZyTn*pi@Gr{~|@ zZ=zo1A8d9rGBZrbYeL1%Xp^;Wx2StU>4(AT%w_Pi$YEvZQ7Y^W9U>LKbN3G29#V{( zdOAN_rg184%4aYDzGAkn6=t; z@o69xj1OR>upuRj?yQlla#{RvyKqJ}PQ#5A5K4u8@1}{Oh32;8HkBGGm8u| zTcj?7I~a+H0%~SzdZ~L?K&AhwQ*D&bv1t+GLr_r$#1D;ky=9wcIsf6>zxSXL$|Zzr z`PHvZ%ER{8*zw81sPu>mB$EADL4NJHr#M)@Jno2jGRgBE9@(Oryh+Q%MI-_RtC2$R z#k|mMGel>1+7+eEp4De<3uMKJ9(Dzv5C6VgNlHG%O1{iK6i|>K`rSWi1|AAN>kDi8 zJ1P0#WBK~%X4boyFxO59i`^U{x9xLZE!3leI&p>|-va41LU9)}&Z z@guv(CsXfPi7f%T z?pc7T`z0!NE#(d(=klha^D=Yul&(q5)DGt0p~+1Zq-jgrt?~@*;`+nMkgg^K^wWP> z5^4Jdv+#kF>gCF;=ZKuK7YO}4NzTP%ZnbX1kI2i*Tln(0XXd>T?jiDoZ%k*45+E=h zeV|iCbg7tp0JsC<&zeh6N4f5Yayv&e?#QRmzuhz=eH~gTQ~SkEm;twdJy}IAwcdr# z9}7d0L`XvDbiU07(c?L1u=UWj#%FG>!=dC;jckH;=;n}^0IsT%`)ZM_4vkW;QBi3mJ1|;AupT!Q<*k@Xty!uOOSxHSc7tg z`eSaLHL0{pm`W6Ak{AI9mW0li018*i zE2&hjYaJYPY92iBsQFgoBT0M4j?R zbeI_wpp^B)+~avXnx|K=dY}M|Ni?fJiImht}LApG$2uk>!7Xw zG;_WD!FV=mPLwqsc(b~K zMWf~Dr1(C8RR?slJp>BFgHdcqA_HViP~IsiNqg43oxC7tuz7uEUy^$v1G~VqT>UA} zoQa+h{BatDEGw-F5RD>A#L8xg-0$2=*%p2lgJ}e#`emT5YZPo}p2;Y-V4?cgId3jIw~YYxe41PrEt1Ky)jC=1V?xFi6q1+C7Fl?sxu-h3YoERcfrs8UrcO zN<3r<@*z``=gQ0=iHq>Zt}UoE=xRe623x;%k=#eb2|-1brdr3ObPL0IDwd+AK+8ko z79hcne4ngD3sxW4JdmSJRoT(WVy@UG(4XSdsv;wyWz}WXo(0=?XkulcZ2)~M%LuhX zC>QY2_C>5s?%YWEdcoK<7Z@r4dn+t{1414Q?aTE*%1L*sN5%mc>Ce~x;d+I>Ti|AP zY&@B%=KYOsd+IRWKfTZroEtZC6ONyu62Jidk~td-CG<=##2G`e9GG{9s%kaw46m+N zdC%MNiPw%F+xj+?Bq~4}s&rly1UwNXAJ(;4g4GzxlN}j}6B(cmH8o$=CZLEy6N}%! z>(>sFV?{{Sf)&u>lr41P!Xjke0&d=*y|eXb)v_eee8s^IY@;3d8><0uLK}A#9*K)V zq8+gOBxB1{aOo@jBo@gd+T7Bz@79Wg<6mzmR%pP2OH#edjo)Qu!aVEJ+X6@Ig3UweCt0_XSCE@3yrcadA@3it%97`yT% znk7?DouR+=_l}nzPnL9>omRWt$z^Rmz&~tU33m5(iQtdD4|_Hhd*x?pYh6C50&o^2 zE-HRzyaZiHO||97l`UD%C#rz5e@*&3b7?UqhMHyQ;fgrqC41RRvMx~KV=wRG!G%3X zZ6QLDVkJTv7k2&la8wX4zc;=7=dr`x!qw95qr+I|o^TAs;7ol-P9$oy4uB55SRT}= zWxJlGjYhyP6=wW3nnP;B1q*}utqM}YIT^by`ggf1Ia>>)bOvVq@=Z^pDLw33JS>i) zP~<7JPzBT(DnsV+P;NIOL#!aAXN5A(FvM43)&qiKCCXQ9^uSg{S z_Yb=lONrS2pVsqILOK5Q5YDDvF(VnyY0=I_AZz!}`!^v)i|mUjSuWd+iHX4o+z~42RjS$pmoLZdV~T@=kfsUJ?p=20@Sfa;$7b5Phg*MfZUck)k_KL;ZT=9=YaQ$IH&je zG*-Bx7rxJekq=e?OeFW0#TQsR>=S3B=N=hC%L6jZ^g3b{Zn6b8TcW@wptXTl)n>Q7 z#DO8s^Lf*FWpTmI%p;&!lz!2+mLYHCRK%cF~*tlrLT`XYq z>w_^-BRW?u{jb+OQAAlplv$KNOz}0y@Om9KlK%3hLCL7oV7*7ZI%stWSH9LaJSzeGD=TdUVIf#$W#$AW<>X%7xkM?quic+Sgj&*hnsh5u7PMy%9+e z=}KI7q&KWyB%xnK1Lb$2*=~rbo)FxSja=0xV;GlsXV^fAQpT4*et(U-V&pX6<}kD4 z=G^Ahd=sMBqb4%Or?I3f1C-hCgnARxbnFVGx%iEImzm5B0~5TN1oa}%0(4MKd2>h0 zr&-b*1rRaJe)EMOu=S$Sx- zA0~?H$grf-qvP-_UQV4)ML2B1ug}$XJdWfqHA}ZnOpMWe4h=Bl$wtssLr1=iC80|L zb@)n0i#;F)?%m&y<-$uXxRSTel8@`@nP&Of1xBXEuJ8p3#<`Tu^GD|;aC#}Dhl#1G ztII3-*uypfxe3oSeO&$Z>$u$EXn$WQe;h>nn2L$nuJu62>gs~dSnAtTZOezZa&=bI zI$usy(It7+2Wh!_fZ9Hm;|4r++|132<%xzRmmOD}uujLv4dux{Q?}50U|@ zv{1_6g@L|=f>l^d6Rhz~8yHgM?#`h(q^CUTzj*j(#B|-sG{Q<~a1b2cmhhCN!9v`} z##(P9IFe%;@L=VInlw;hIH-KkV|=gEsb9yk?2)fc4$tvmmR11GmqhMAB=%P89}9xN zd|`c?!D$RPvqnBN{?8l=j3FJe;_>d@H3bS$B=~uf`u+z5y`;C(8<7Ja>2Cv8y49 zyGz&#qxL&O0B~>rflE=8m#|G;6Ezw7ZScIU8Hltu0Q&d)ubPrVB~c__?j|574rUL-NjD)_8^4f{ z=rQ63`-?UT5er?}&23n11Qk9Ste(=^&# zf?x))p=zCFjshQ{IoSt90**?IEv@jcU_unGzF$b&(Q++_(JIW|dzgHkI71C;3_&HJ zu#s=BOmTnt(&a&d9-`3hP9sjOrw97!m5yqa$K^%sdB~4hSEuOhQcP^6pVFxK!zuK3mCBSg3~}b}R6XhEB}c3!OSGxV??J zhc}3hL7G~Q_Onv3EOm;Uwa~;~nj!nA{hQjFudA)Ro=0;ZVLjyJ9EE3fS=@PcV!_*v zJ!HWlr+Og>*nr*@q2IEb%$lV`6(oJcw0N+ZoJ!2zgD)(UO!3S<{OKx%avBKFL1*g# zMTT|B7`c)vE3(*WK80H>F*lUthcHx`BxW2m3boRb%rh;C!r1)0+ki+R;U&|vAE*_R zes#@M7$`GU1fLSk-&?7tSef>b%f>8IPE1cvKR!OfH}j5M#EkA-P*L`-(&v7uwkJD0 zIyyT1MTE)V&o`45LYJfV>J{>vdGB7cfUDgx1G7%|hw;bd-ZpnjFPpu|y}@kwlD{e| z3{9jtlGuSh8j1s1t=sBqhZ)^QI7JEE*)eclY5U&L)Y08tsI>0LX2{u3zkNyz?)eS6 zJ)tD__1u4IGyZbtvm0#%Cum~U&vY;4HVzh^ch|@H1{F?SA^}Hp$MA!#yt=WxUPR^2 z6PhbPqH>}+BIBi+C#W1dS?C3VT?dxCH>cNgVlIr%!GUQPA{4u*S@V`_L^irqUt ze#<0ny~pND`R9fzCH9c$Wxs;4S2x!^r>^u_;~yIC{BP+yAp1+p=U<)Me_V^9Dn=%I z^W{!(AB#(GP?Gi`v{LUXpi-Btr`JT5{Z}PeMInW1w*P;ov{H&5{L;$J+~(d5e0i|u z?pm<()FD>$KUKsN)P1w%{MTEI)0m9X=55z4u`5T@Kfx4-)gjIUgUa2Ny`0N;r0ZbV zI6kKN=fgb^W>4vsz%3N}SKh=2#+;XCcd3$;#`zCSst;}L#sGwK9 z8mIi1&}1-i?;@yE7ZH&WsyCn)k4QldD3P=5D4LVP;V$Pkz06UKbC^V0uw&73_t&W> zV~vrkio2=tQ3KJsHlzhyte#$8;wWLTsa@&F^T^vVrk zGA!jlm(peQ&^VwNz8ZdRtP0)w8Og$O2zAafQU=nK%xs+-O`YFdc({GoJ`=et|F7Of z-}G}c`YE)RcyN4G2Q)5{or89`ft0N|%UXGWwTNKqeLaO?J6hr)e~!o`y-VmXKvA1; z-TT02X4%(LTDo6o{FP~WdX*Svp8KF97l&&vPnc#;vE7f7FWudbN6vwJ=~SzK4A;a& zFm`!-q7%ztzxZUl2a?2-1@$(Ov__hXlTf!q$?GvO754K%mt*t<7;#g;w`xELSs4ZR zH~p8GcZlJ*4nP@gdQu2II(QjR4q00u`wh{vjC=lnv{cO#nzC8qfoZm3|0r-d)K zV7Ovjx^PM8tW131@#cpk4+(j@ z#}>CKhuObziR#Ka3-;oygb%!}T0$+Y2flZaobYfb*2`(l)wwk>F+pdT;5}FF7$J_| z$B*ikmX;0<4n{_7D!T5LA5GNd<-_TfzmzcIqjyhqcQnP-^ENhe%Yk zP~Y8`T+zqo6fvfO*uz892-!6vYi4$M`2~nI(6?~bjkPZ}Y^^@JKm9&flJwp|!|FlUa5#oy2>lII>q!RVn{+&oJP636Ve?j*5R;phekD&*d6&W7{ z#EdqS=qVG=sBYiP890xx`X0>PbV)w-g~ykj^yPcA7IQ09$}z(uIKw|jK;hSMsPs8X z$v9IXYzZN8{KQgRQU(QcS_hN*XxmQvCT*nlop!VTP-~eEaa#N`;?I8;HN0qj^X^Nz z-K}cq{87KC`rED4n;VtXVnH6*oDFs1gd%NnXJ@B5R}|`BQ+xqFzUAuC$$#w4+$t!E ziVd}BWJGctXZ#uRvS=2>$u0~ZxHw%K80`W)PBl{9KKJfDs}6%@;9hlOZQh+8Glk3x z-PZcCmu_MP_t&-wHy!o)$J?ugMNJqT;*iep$!z}8+Wo-1>X;kQhwbHigmb`F?Fi~& zhS@!png3ycro=y|d%5;$q#|(3>~O^I_xhVrUcj3((+u1YO0`dS7ZqpAK4UwXRNo5c zz$1x#&rWgJBE;x#wEyrN|54=>t0fn^`o-H;H1AC`k{PPKqnY9KH^;LzQn9x1IVMg5 zRz{4edo=bizf)s=cgag7XVM#9FaCfj8JYe*9zHU477Gz9+un%BSnn8(d|WhmCShTn z_y0={(O(s_Fv-AD}6to8WHNO_ew} z>W97fPYo8HV11sko4o#~1*L>KTiKAEX>74Tm^KHvS|nOgWH0Q$_2TWY!YHC-v$N-Y1JzU(|{Xq7H! zX)17W3+om!bEJ8dyge>&1E{0dLGQIoF;!-zl-CJ;0qw_rpPf6&|;Jkuts4e^F|TKr!aH` zmg!V&nY+Me!)Cr8xcVUkLKyRt85TEOntn80;$9{uBwXLzgnp{6su9RmcR^r|f$x^P zxxqEA8N1@&%W!ajICJ{Cg?NoiFU-%+dwy!Vxw*l@#f^`Pi%U!_hT?MZ@y*W6tgNoS z(bbKI!RhTKeT?9$Y>yUg^3dLe+7>tXr;-+P%{=Ox4=AKT2>gB6%#P?8nk^3MzZ+VQ zmBn^3DcZzx$G=^v*X}{jFco{hl)wEtH==_2EJdBhICIb^1#NHMx@a?QH}ZRanpEaW zSKff)YlB>YVH=5)8wrc&KVd|L8O<0x&ft3MM@JXNJR{zSB-*K=F34Kvk!xE%bS}Z> zWs+F+Cx5(!ZbLRZ?Xu|d8qDe2$8WXm^WyE45H67FPu^B`S|)j$dpqW_=nvYANyr|+ z@Bfi9%1NyCiYG*}Y`hHkdqWKcW)RcX*7ooVxoh(51}+9#;@2oE>4OhE zH$HlBxIY(K8-(ujz%dE_)mE$^j5b_}7qb`l)~C$p_1H4TFv(jpGnBjgY{*M{l4p>> zv&!NPuOwV1zR}MQEXDP<25Fc#;9lQoLmuZL$k+E$vlD^0r8@(Y9ol&hVW-VqcURtv zXP_X9r#W$wOF5_VU4xrDq7XFd;}Wxfe}4Q0&ufz>`YwOFMYv-J(rH{NDr z(d^d83Q-m+Wl5lsuzGcWbP@L07t5K}8-!{FUJQkaod5eV@z6J?PVhe6aq1TxHNtT^ z?UBwq@8dDrn;3qCy{-xquZyi&A?OZpe-P1P-JBZhX^Q#gWqI&L-sf)xZJ{axj+o4A z`HO#S*H&fh0n?BZoNxk_ILz>7ku6Ae0VwXMzHHqj$H*A4Usu>b;Kja|a%^$OQh|RF z-Po9v5XCdRhk3ul2@wWIclZ>L?i0TX*fj8S;fBPQ7r-! zmhbATPlf$ZrY)7z^B-$L zD^1g``^>{%%*bkKSYC2bvuPMd-LwGmS}mE>p4$9f_0uT4`m>yxSE;$jbwph-&nqfa z?4q-GRS_u6{IN0?%v7H=!YNHKiz&1Y)guTGbcz)orHW}f^51bqaHud7uK140moK@V zANQ++xDr6Y*b|&iB3ww~$nFb3U&MD(9+sd`tfHzLqTM<2lHF7DU}5iXF&A{zRho{d7~H9>fgZM&+4vUG-k-B1Qq0e2ew`)vCwVCa88VtvHRj{a%Moi= zc3Qjw%4am zZ|3!~&oP*kk(@|XZKj9*9v&X{_HJHZ3s`*?sonlYq^fI47a?}NaCOuD56`vR5nQ?&8?_$t*_hzQxeyX$-X#wVWuQN@idV~GkovJb=(ckD@+>#xIt zxqQatdM+t?+vv4Iu*>%i_!%;yPF|h9uH~HIocQ*Yv$fO744SFNXl{H^`Sn|k=fW#+ zddl$Um;K4k6VrEQriMlyPTx*$fAlfY$ZLD7`K!26%7v3)Oj@!nK%=~PWnyC*M9YY6 zIkoWOBx-k6ZI=0acT=MTh|toSkflBF{;xfdh^2HhNp4SvcvghP$H$j7w(B(+5?@9J z_q#>`lonz`lJ28CL{|TR&L|hCC4JN5yC~S)ecStYr)j2<>bX)QRq_em9!LLiG0`6~ z`!ehNqH%{HB@|rj4{rvPd=lx0+?yUhk2Pki?LI5@J>~q)MsnQ%Co8)ai6D+VM*}^ zeWb$I4_8rcObn#-X3Bve@Ajj4Dz<(^VD-aI;azj#eLO@z2EU3eQCE=pH5K~LN-(^l zKTh#E<(Y|nT?uk}^K5kY`QA|6)!jVdwWGwQGdq3U>+n zK**tI_jBIEClLLkZufa@7p(QA1Xb%Al;n>J4fLrpEr) z{(z-U1TCFBKkiAszzbF<>NIE4A_N%#Q{XSetqx-RS>i4EN$bD$(=Q+g$dHLZS^hV3YfNCj!c;n_l! z6$wcjMJX4R8ZeF)neo~%s(W-gj)sGRC+&-rtxmDR;((LsZ zQ`CT@0!?~cF)HRacExj|^vh^9?NVs)Rgp@_)e^6~%3Z$%rhYE*!mF=ru|#|Pv{4qk zGQ;PHCfNH2y(}!ls04_H-A!z>?ic>8U)>R(Rmu=g#UcumI_YOPhWaWk2p9#3u(Z#6 zhk%Z={z@tok&nFzfy^qpXs)L+NuIbgtp~rzuU|=J3ZW&$tybuO!jMTrl7J*ifD$p? zXbYT?rp3yk!lDrhz)7G-kJi#bz@jcCV%}$A0MN)S)9BFd0&UQDB{D~M-GtF%zQFSE zWmv|kntSw-PLYtMGpR3RU9(s+hJ%MZq3uHyJSgTQe9)fZ|9Jtv=r=(Dz#<{Ua<4uD z(5E_iP@2{VKZ9jW5rMj`=cEh`INTMl4n$k)qJ1eXj}8Qp+e^tQjl^VO`ay3UK$8B( z>#S8oLJ5FU+O6b`!9Zeei0F<4}N}s-YwbNS6iii z-9QG_a>mg#pUXW?LRz}IPTg7)5<``Q%4Y4qZ*qpMtmMP5J+`@S-Q#O^JPb1x0t&o0 zqrEzhOcJ|~&b=hhYrMK2OcKREZ6zyGQg-vr{rNLJskJt{JPSAeW|xiEw9Z_FVB?et z2qj1$_;d~|glv8KApC2-fT?N-cTiX~nGHQS-s$Y5Z8>-KqcfLBp21)J@4ru0UdhtY z;o}=Ha%yTA=a5jnqM{@_I6SO!woMu0uq5vNA-r=NG8OajV~L4d)vJ<-lrqc9~aa#R|f19 zxsqHzXsuyR6g>S2N?C*pfHb9gm_c<<3NKFFzP-VhJo790C^g;apKe#%mpL`CE2 z_28q+w~*{6xn~Li+54OclX9l-@!4+f=)DX{6zsQazWU*}()y;04n8{ufpvGqb@my; zpH;Y#Y01mQ%lj8j&F*$G&7NI(p)tsOXmYbb94@CAd?c1+x&+AI-wGHAvl@}-_$~SR z{_)GPCwFlCO*AZ{SAIa%j@pjP8jh#osYE~@k0ZmP$nr)7jhdr#lYgGz3O+Sz^;t{9 zZ)BJ>iHiZVc>Y`eqhY8^&(op3HXWAhp1lf2y#@Q0ye@yeLTulmZI$z>-4W&S;n~st ziLIkU<=5H<5mq$;m%C4~WAvkX1kw8vLb$3d_VpiVwQ*ud0W!?3kRH|%>C1tq!TruQ z1~cxIU{ADtliF$yDxVF&JG25YN8VhT7J*Exz0R9;g4baR%=hTslN`&`gvAbF>QnH& zc_&+2nCebSEDR#v8Rz5Wfu%BF!eq29svYYuJrCSjlquQ^z-YGZ z0h#g zJ(@G6_HR?9b9+k>M-ZB9v{Kyp{D~E-4~5w3MO$-+kz!A2UWuNbNSmMUfWlx+oTe#x zx39J1=E#0}wWUFAoEa$<>CxGVb7l3s-fOb>SGNJ}sD7x55|i6xGt502(E&fZ2wlpf zolH!?m%DcA4Ep+8!~5Dob&!_ky`?Lv2lqQ;>$1{s-#^wlyZGBWzPEbsc097Z|8rq- zU&;&OuG=_fuU?iK`7F!c)3_xpcEO+vUunsUU6m^5ltfC8{N1pl0KxPx=9kU*r7lup z;{usqG3x87d0rY5tX%tFdu>VK?4ig;N^H2z7H-n5_K*Tj=2uq}?qB60S{}eZkwFTz zbP(%s9?jX4y%oR&m)k*QtNFYBHP;A+aItLQ(_Sj zQR#=LY5B(*Cs>5NmYG+o5ey&z7k}q)(sq21IW6nM*`Xca>3Nm;3`?vVI1+S98+bpU z`E*^;eJ2PpDyY2?uLbP{u&>DUa$z#1e^ojC1U0Q zq%k$L5yOJfkzd(!XFE{nn|w2wAE+_zeuUYsDcR^MrhnJbDf1Vo-PKc{Li0DL2GYp; z^mGDbJc1APB1}`;M@Yp>@K6ZCCucbJC#17h^n>SUNXkNQctC})($L0mnc=K2m8~8L zJJ~B%4qxm@PRYD4r)4!w{T>I(6d6%CuD*K;&I|lDm;>rxvu;b56KJ4It6$M>lnYM$ zLkCkd9n+E5BUjETST~uUa&5B_CNO$seJw*#c>l8l}-Rj?Wwo*>P*bUqH?r-|Kiy4;G63?#8mCCRkssc{8 zW*yecVCHn`b9PZhC)Cg;l-4SeGr<|r4=*C=q2i!ZVF$cM5UeYzH*|D&cZc`6e0_Z# z9FA`Hne#P98{!}ZOZ3l*&B>|b9ipWG^$vcRUX)rNI=XUlK0^xavEo4= zS$tnVJ3H&)@hL7ovGZZ&tm}Ni@+siy$`kudb-qf<7Yod+a-L|FPY&{`x}!srLsyEm zl2@0yFZ(ZdlabGbxLq%ZXPwuRzZ0&nF>+rq1@DK0V?w?FdI`f~afErlJ~1J z$Hw>O)A-j^$n~-SR|3JJK>n`I#?G_9))$K-hpaBw_?e891@Oc1Q^TVHnF{R@gKh8^ z*8mk+=l#%5WQ}PX&!nK1S>H?Oimh0nHvx@~z}` zM|nz~LAn;7zxOe2Sb|0hciz{5@`(DW`lI=p?z8$EiK|=GRmfK7S^IMVuGn+ejTvMD zKe6MecyvDE9>t;Bfl&UO>eb2d$t^LSn504|l#S|2mFjt@RX(`rwV=>E=D(5f zPOONtR!#qls)|A&-tBRuBmybfevd$YBd3?=Dcxur14YJ?-hAFO?mcz0>ErY0em_AL zCjWcCg9lE@n{$Y^z6o{!dYR9}!G>{RSw}?bG}JHrGF|dT5GiT7;Sf%gU&q9oUz*Zzc$ zo0yYlhUHxX@7VjXCQE=}Aw!WS{s$=gr357{_#=?-A+cfmA|VI^1RV!c=jZTq#!Dxw zf0i$OmmePdf=dBW!&*X3&CPpmt#95K{9Al|ZetM$Z`WD-^nc&@v$TR4*?XWz*1>0O ze#+9*x)G!Wum=CG{9;?B#|Rc)T)XiRTaCw_|MTb17iNtL_%3{R8!uGupYPB8O?wU$ z%s-RR=KyHJbO21RY!LaSw-=2W$r4{7Cw{u|YF<9QiuV*UF@=|5-^n$_#>OUbC;N@Y zYsnVFQwS)tObb;AfOM3t)th!#^zvh=x0ipHY}SsOPvCXsXXZOwzTLlfVoo=Ia@;*X ze(c-%v$}{*i8^!86k(G|!yFCn`XoQqu=qHPevDZ3c|O19_s;jServhy_r{TZ}UxelEoMl5Nd9xZ|xfPpOb0TH;3T5a^!Z{NDs zh)M@8)!YmG&kKN$CJ9|+Y^^Qz^krPiR249dJn72#NUNh+nQmx|U=Z@yOGQs9I#s@6YSFcFP$a?)e_`iP_Xn(~SXGp+9 zkI>`II$DmQXdMtB>*aOxb7yqE#-v?DSXe+Hg66g&m0eX#PR$+2)t-QPe_A2qFM@^u zt-3l~Fakt{` z4rljyzvnw=j%DCah6$6M?CV}@U9!j#6B7$3OKEcs4h|QW1Rnez9@~7Ea+ZMhjHDc3 z&kwQ>vjuXNe3#myd=Y&G{fG}p9C-EFGtl$GF`qgOUxx2G0$yJ}u0vJaZzQh;p}{@% zh(aO3C-zDYC7UOvZf&J*nLziLq|vzJY(_`Y)SR8mPI$R{6JA9XZYT&_*Xj@xle7uG zJ6u;e{V6(5X=MJm==UJ#8|C{T{eTelh|s#!c4&8Kp_|C?W+>T(gH#}9P;H;vsEcVs zI-4U_k>Pv9c>?9GT?dKWDu@q-$K5)Z))g_0Dq(Y(;Aq4-(%~?`XApE;(;@~8S*s@_(A%sXQHQ!#-b-4piP|Q{>$@_<%a-)8{k0Z!ACcfoOJzYSq4YCA z0TB#Fg0JpH`lwi1MTrw(j!{k^HW}u@QSou=syi*W(85j6D4EBMOZR{o?3djp?I%Sa zMPH3!NVZSBZ?k7p>flgiyYG0IN1Ek7C!Qn{czh1-(|%a=wI`jiT3}UGxvhyx;BeJKO>10~)@qnMxz$bWdObbh|(>pV~BdKj$^ItB;bH&j#4Rs*KJ zpu>BD{H_lkexn^8?(S(23#JhJa5-E_=+}cwicrKZpF?~OktRUmlQ^`6OP@yh9ucu~ zyEvWVWqteg(JSaR@AT>TavX?bnb9X9(NA`f(A4ZDh)!kh6aI=gK)$rPl4ES(f4`lx z_Iz>~^gQ=^OA>TFXZ$J;cLn+D80dPQTYGuzlJ*Va5D~$o7tT`HFr^|c{z)<$EiTn> z%b>&Wg%m)YC2&R-Tp4uz#pSNDtB2kH`EX72xzhOsvi7>-W$d$^Uc7Dj^%UzJ4bi2c9Ejr>RW0Q%Ce7T~y-~a$=ybGQBO+7&*SFsGzLE zBMp9jewLtCqC=kclefRzyW8ta67s)3n28)jI1jkSby|-~`i4thx1hj}D||(U00Z4D z_7=kj7(Nu$1z1>k+t~c5s+zUnq(aD5zJkGp2XF3g*yS2l3>%p`I}fd`1Iq-Ka)@&P zHTAn##F$|u1eNl+`U+7RQ6xGzg5pvckc!$YWid=N=hvVpP zomEwpEyWE$$Zgf6RZVYl$+_`iw~?NOd_z%rM_DDfqocU2thF^(hBjZ}h>V<-KcnQ! z_fu9S<+tO%s(y`CLB=ZCemS$UIBY(4zvm~`OWi5Q_uYx925`)M=1wN4)Ty`Z>h;DV=@qdQblhZx(1KTaQHIy8^e~1yr@!i~pTZ|ML(38NkAF zon4FqoL>v-mPGeZ;tTD^q3#`$6sl&@EOB2BI+_eRf zc`p8mnYoq41+}%`D(c!>rJg!uo^=pveT5X+LY!zd=2A5Nx50H(XXDP9WF~1YZ#w8L z;fXsuh*6#6dIU~kFiIFHoj$Y2hs46TOi7rAMuw2hluDE;(ALrFTgpca7;Tl>mOCEV z*qu6_lJ)BeJo@SR=^f~Sk9j=h;qldsS0)w~7v^T><`$+Frw!CK;a}7(%gf6$5m80C z((Xc8>~?d{XzMf3(s{Dz4l*(@$OaCYRxS_3 z?VCKoCMu{Yz|dCk^<&oQi36aw=G;fqarvbw0c@SK8g&yjVV3&MPZh zYT5`lx3jYYEbOMHrXG%vV^75H3hO$4t#EuHJW}i`EP>dfjA1Jp2JElnO$`n3H-vo~ z7QVg+t0&7@**knExua84z-(YtULKfg8dcu{)(diFi@Nu@TyVJx^ht?Hb9@Fm7mEkk zL&xtgC`{SH31a``hST`9`Pthm@n_}d=jG%W8+x2jkoIpPYxdB5%#yo!PnWKM7zSGD z=txLN7#JvCXz@`H{J;~iJ9ahgXn~oT-x?+{*4uY+G4f?8VDFaZa=}0;*Q)}jg2Sp9 z7q=7PtDVr7D++SQmJg#WEN*o!NDS)gF5cD;V|jw=>QhtdTyActfV}c2o7IpWcY-Pk zB9sBP*!?glEu~g$KdWR9#2C(ySukNHV%Cc?u&|LnNVo=Zr^iX8JSI6LSz20>4iOb1 z?_&w>Qw46r<*6kuMmaxeKS@czflA>e7p`AoYe%0Nlb;(d21>QTB`QhFR-~7Dsj087Zm6lQy742ka*q;jvX8Df zyl_wPf;gg6>UF_MbB1%lfivNQvSsc2670Bv&^s_QxSTZI6Rh}!Zjd{{wzS4KMkQxK z4XFH$4Yh%!Kd*Ugp_>o5$bL8NO5>4J$8aB#d;{zQR*sils-2tpc!fw!4h{3sGv)?1 zuzCI*Cn!Q^5Ezo=tU@aD6q^?XmocS!haDniQU=TKQC!mMln1^GL|G_v57W2+GYjfVWQ+(_b#=iS=R2ZpR_F z0hQpui7_(aZK$AOY0AE?BT3N$gWWNr&#PlSBsC;N-e67>-W+{>eMAHVJw3g%um!t@ zk=iFMMXNiWzev--8H|XPL`5zolnF>ODUZ%Scg|84t^ErktBYtFa~fggw`6BY(_zA| zch#>W)~_pFS%4tjdw*c}>tjRG^SH>}^952q%S#vR_6UW#6ckxrovwB_&E@cWplr|+15E!m6ZdG;Nj^W$uUJ1VJBKVW_-dG+O5Y` zBVQBWG`GeAi=$PJE~n~ee=~M~9!DQTLI1Rchx9{TI2sr7qRmJ6ni&l*}mT|JY`Vg(CK2<}_Z5NG91^V=|ECc4`nD4H`6)BpTy^LF= zd4VCP&`95dfHo6JV>*02z!n8Pn8eO?oEl$t#Llq&`}5(!dg?*o)dLSgPcEVYvh@!M z^pAx)dCl%IscE>5o!+`%Xan~@5Go;kq+nvX{Yuy&sei;Hp?dk|Cm}Bvxiaa)6FKIc zD8b$S_V&y`adB4G!V+VX(|E|Gk&&)fRa=wLU(eSKKyQ#7R;8!UEVQ)fH#vo{tS!63 ziw~7)sLg1|ishnx$3a!opv#X&e0l-WcddMPc-tXk>}>3zlxr?keZzy1qb-e{?e*== zKRCG1d3{loKk?&gUHAm#QR`r{YyIj`&lqLeaIx_7v$nOpA3J8kowxzBfUD3mNU(hy zn>yOs1pNe;+Q|7z#~?H5>G$_Ozc7HL65HX!Ybk1m76%M_W!g8=($Y@1vx9@JOULJA zq`hvu8P=JZ7pEJ_C2Am(pFkebje~P5?_3^=mP=)ZmL zqpB5Fvh*^IyNP{-^vRq2{Om{T_Iq(j3g$ELGptIiojZK(ojiNCR)k|D0A=B1v;=MN zSsfdb4-dY)ce@}eY8=;;8-Q__ZkI06?v9R5ElrI=`X>-{f_BLGxOv!>WQ5p8drC&W zLnGtAvjAEmAL_Fq=d{hzy#kF44Y%E3>Rn>5X*ocjN`lp;HBJT1+y42yKqTsnh zT_YnsUBiW?r2&yOZpDTr$4a)!40hvI-{XV3J8x}m0C)0+9n2KW;g*KhN%E<7(+xP& z&N=O1{i~0jfO!$E+!C2qo#5hf`=ry<>jp{K9XK8+x0% z;P5VsmC&Yc!I3v-z>F`)k$!<%p0nZ|5@Xe8HR^rN5E(|Q=5u10U<5lg*0hO2#uKD$ ztj@rSFpeuz)!3Br9oH6rBUO!3?3@|124o#Yd~E_pS*zIIRy zFMa^5zjIw@27jW-D0KS2Fy9;cbn<%D=+69qZLQ{kZoUO(B_s`HT zKb~<*cuKfA&pdwyvySrc@Dw2X`TB}(V3G{{Beqrp?An{}`un1TFxPwh1=|A&-x2?( zhO-ud{j2z#17b)j-#9<}ACed&`e6D4IVv;m3sLK)mt|`&SNc6=VdX)9r||G@CwOQhpLe=!tebn{QzRDp8$9c0Ae8f`GUD@R5aKq zO4@Ei=-k3?MkVFoTTuq+8Y*-Qkz;?9yzaD#n&(9yxq z{y#tai$OITtDw%r&cu?N)`N%gSmvA=Cth6(>Ng1}DbdQ%C1`xYZwKJuB)cxNKMACV zc2Qw@V+n}&5*CUYK&7?E*}+i~XYW}MXOb#zHI$d5M17gdESHVXj&?{^;H|6ty;3*1~*Q$Y=yom9xj&mf!); z8seKC7iwHtNwWnV=8Vl;LL3m6w+x=y4jhg#qJ$XI00qpHjNsC{zo`w6pW zaJdwK%ti(z$_gr=ZZ~H1Axz5!_s|>!8x@-TjHXpZN*l$=PzA9KmllM>S8t@RF}~NI zKv43qM2`&T%0&p=ZG%YWqtb>inbWvD6$0VUxX2M=LqkJRQBg)o1l+vy+W<@@ExD2M zE77O4C}1E`e$@sXh}8K=cS%C}`3t%~j@1v@cSD>-h~vu3<=*iP9)RSL~Ysdyqfrg?FR2w$|?MP6k0nM?N4eFriNVW#v|0jtwJ@yan8x)EH(jgbKI4 zPftK)rZc&-Ltpx!z&kpZ521S_+y4dwp@@ND-<>%FvhnSWoQldjMRNu9o~fOSeGPj2 zgggnQ%^C^%R82f|^*?U+F8rI<-atT-(@ML~`uD4wq)#8wbsJJ^MMCY>wI$e@AuJ?OwUq4HY@SQuhYGY7sw;ws=K_by9CRFjhYDb z!x}867W|c2qYwkeAI9_1x= z3fnm{fWqt@TgJF0XBq9wx0HPage;z6&iNzYqiI)1)Zk}Uu+pUn?Mu2nkzmUjL6Py7 z#T8@hCY-nVgTbteZ!9iW_A(v4TgvE02eL-V4)N3d z>busngE(z(*3Uh`AZiWj6bVKY3}oX(j_=X(DJn(Ms(20otmJ)MIEm}+;h{a0p6^}U z|6l@E%qW@blb4sVmy@xRv6~a#p|M7WlV#~&}^*u z4f#GZqM(GG+U5!IA0|Sz6PK#~kTkgzC`l?AA_bFvM2-#@RmPTX#KVL*`zzz+^TY|_ zH~{P(xF|aU)%$+`i$wnkN&YK^|F3TLsps{9=JRdwPDl9o%h2l;K*9^&9`Ia=SRur3 z{Ovve>2F0Xp~3_BNdpVSI768%O{}aCeg%~I7(-vdz$UBLhvgex=Hj5o7{32X3P4MUA}lz(%6}}3OWHzb|$|o@N%zrcK>GWT~OCm-O<$60`4g5 zD6T34wSpRJ8-E+=nu34mI%w6+R0NHsj8NJ#f&`FILSgF4hKfgRP*kMcxuyw$JAs}v z+9%b5Q{D>fP?O_`Aurj`QQlZy0xAI&gIa(sAm1Cn)#w@lYe=C-L_~)~WXI%W`{d+6 zXp_^it-TGPLaWnv%zU3EAMqx+PsK#=$JUL{^5Y4lLvgOkKETgBz%L+RX@6OFZGL5b zdCir=*JKNo0>QjTM<9RLx;y!|TF6@oImS&|Aizz9(K%MI97bp3WvWY6%==d}wvoH; zbefvGs*1st0=67-E&*OehQvL;7~^YLtZQ=a7q??BmR-R_Fc32|!HtlQi6+3mBSB4+ zFxa~h{mSv9sqJ)ilY(XZ+mwms;lY8PzW$Ql66Jh_mZD`r6>Nxk(o$RFjsd{3tm)j6 zJ*bsw#GH-pUe@TKwbxGV8yFbq>2Di@|hWe=%7@#3!Ak-;$+8|K_Z(@29NnEFx!YtZi)_ zU`RH+WamWgZal$rSqh&L45u0!BSkVF$$z9$|KRI{5vdT%{IjEo8SygEy<2 z8o)|kKE5Vk5pR7x_cY(M#vK2hcC9`~QDJ#>f7`L4u3_F+V*_2IPtH#8PcIsr$ZGYC zRgHCZMVQ51)kSr6AW)B44dsk<$X0^K+`+}c!Np>UJ@8hGr-;ND>2PpzWUc$CULa2_ zEa13Hd7uvks1}e3W5#fng}S)7xwSOI?EK2g%I`(9x`oe8DjeRpND@U6#yP<>t|z2e zdvcV8scL~+1Wb7Kv7Aa6!S0uHa4Ns!Ejeu+)&>U=U@pBoOfqWH3k)i?>;uHbt+jV2 zCeB1(pRAp_Gu#$SR&`ne%+PHeFso-+ zCb%67K?xNIj@`I}lPIwBcz=Pgi@8Pu}4 z>2W>t&PVe}W6WnJdcUl~rI9DO6;<&h)wv@VkiP$lp;gaP zi@`y`%V#rkOU%WR!&8(D-;lZ6yZaeL%8!9)KCDBZ1H1^lX(2a)UV)ow}L?Cu%HPLX8M<;`8z=S zUxr|WMBbNNDu#y7Eo+a?qW425@>76vLKraWblyE@00N{gMke%)9!M~D)_*u*K)6Um zq!Yl>b@~iJI?FrhJNC@Uy6@D5it6ckTj)m9`X1Njg1A0{b{-4KgcAl-ln$B6iEDCt zyOzC_xgszxB!K*_mML?w5--6c!H~_csH133ihj*wyXL!7`)k;#??cwm*N5)w+s<7O zgOfpLpoh0>y>)YS$M?FHj^fUi&XTG@RZZK5B}FZ;!#nlVUlyWjOq$4=MCwSRTo@79 zqL$%@Of$5ZS)~iqx(>Asy$;p&nxWsT%Sy|tE1b)m_wDwH4~UzEI9|@~8oZ4CjyjTr z&!-b^{m;&CgU-$|g#wLC~@u!aCplr%~QBhSw?P2TAk`$Yu9}pud{;5|}wq^=Nc@1NN11|xq1n1t@ zW`D;F+YZ%=N0)Yi$_Fjx9cN^Kw4G;d48hz~m~JNtRBdUo7mAe^IqiIO=T67oXAM;>sH_5EdX{JF?l`07_5 zrU;x;I|wx9z-v!Dyj#B|>h0X+O)z>p>^*FpaI(nL% z{3KkRsaTSIaL^g@a+tJocW(^*yzX}0EHk{^4+UPEi`*s!y)25)9grOK@RZb<+=x1b z%1&d6G~xtz7vU0w!qNOBOysPIP4aEx<^yagoq!bkRYc?#gc)@A_37em5Flf?r~8>C z{2-(3tPI=&3ucqtY~)E{9!7@xE2|4J16#GMP25ceo$mL~09i9NE_7YxB_b@qyEwaw zAb@|VB1Y-%?yjJqAavgUhT>@=D)3}C@I?G|PyEzp&5yoHp}xs0xCzKY5`NwIEApPc z0%m>ZfB|pOp>eQeu%_l+{6j?0MH0Ykyew2dKMY~vm1MU27Izf&nKfAb&I${l$b8AE zUe(PsMu`;D!8TRnEh43e0-`!UH{G}R%neF0;)?z8Z0qRk>}+Z}1vKYR5yp2B#xL8( zFVOf8&S1t=YS7my$o{B4b zu(riOpiiDXNBzPZ`ZsM1wRs~-+l&=g79_=iTEi^U0IoYp1a$8&Od3N@{?_;_2MNXP84`Kc=}0Ae;Tlu?J!zEWd~X4bJycrnfE;_(6)F!{y~B|H%1v?v1D^ zBBS8(z=;tdPX5AgDFH^(AnekLa$3#--3vf}gy@g>F^GeB>`@f@)4#KT?F)M59NIUO z*hD?F>P%}j&O=RJd$-cP8wR}gH9D;Vf?oqq5ru(kzdt8(!Sec)5d;KXzU-C;J%4?9 z5P97;h7BaU7I~ftdQtxS@$3U1k8}{;T|?579Z}%6wg}qOLA5ZjjqT#7`l&AHqUNjL z!)}$L(c#fSMOm4YoSPhPR$|!Oai;g-=;$}_99ar!YNUSrLUl;c#mJPG z04gvWX5whLo1~j2%%+f)?i15Jjuif$PsMPa-tRJ~IK0z-b@@5eW-lxy!CPqT#FwVJqmP=3Vj3LQ80() z?otwlGbZ)f3=~mlOHBA+!d-v3=5_2O&@`7vzB|}B>bL%Z|0cpb*+7_KSj|#WQrs~> zU)|nW|JGy|>eb!v^r!dfp3d4l|Ac-ipmURIi;8NCG~t?9Seu(&TAN*(o1I$vy|S`A zGcyhC&f9p%6RuRXe`I$e;jHZDulfu`s~ zk-6w8V|elj_8wvz;=`h!pI++{$&&s-&H?z4vuW*~lb0c_ zHX{;DjV|PqL&%!nlEts=(1t#Tso6E?0c0S;10p?rmC(ap5nq*Y*Qc!-lEPqyll^^Q zLt@j?QTT)f^|m1YGBBcZJ|%y6W|({-H)> zAtCfz*C$xv>b2%TvOtP}KglsH89?Kmt$)Yx^E{psLqgUi=8h66IDn2`x2n9{!O7|5 z@RX#Bo7czV0u)8=`^Oa5NZk9+MijZvpJZtYpR18{mN0Sg%40{K2A&kw-HUq_eo`Tf z$i58;>Z)?)y|XW0zD(9kg0&fHsz9KS#zw8S6$E_jsVoTo=k1jbE9P5UfMCWCZhKOi zo}v2c_7kCm2xfmnt0U~!W-5#%dVGpwsu;@cYr1UOUR3yuY> zuIry?&SN-0wnsAr1qI8&U{-vtma z$>!{cGgpp=W&ccgt>50K#K3tHAxr$is?`n88r|+5WM)=KCMJhxSL|}xn&2kfz4$Q- zj)k$=yhXDhx(ECN-CJ^LD%db~u@S*5NfOjK`Z<99s?N=gi*d>~gqte?V6&_cKDfon z%abQDkjL4U)2qrQV^CvLtG?*5fsly`;0%G)8q9V-=|ZqdQ4pH3#Z(Bo)eDDfH8x61 zt1J6pe=q+w(ASUKr%pcWbPlp#>S)*#!ifxjEQ--9$U&V(az~< z3CQNt&t;LcrQRl?$6tHi@bYm3!xH(Fv{5T1GlmcqDDHdHvk8hD3dy9Tq9N(zqOkRi z)ybEtpi{uI9vN1qfnq;&>BOsl7W-M@TY;C4!I&b$`rk;ncELx#C}M?BtjT z+@+9l@yyEhUeo+`#P->X z#Eab-nC@ty5?#xFK4vCnuHF}m6Aku#b5#jY+SLgPMr8T9q^+)&Hk%{smg}w}zt|1Q z3EPA?Fcb>NNuypyCW?naqunzbF@Vm6J|sD2IGH6?Jk@}_lk*+_r?MdbdTaMmH>9IL zeE?q-&GU*n7mX~N=083Kzk^{c))5ke{NVz5+w9s5wbXUix3t$c94&kLVs=Pl2XP0c zC@|Ev#FyI&ArpSgEN7yGQs37Q!p6*pyM^_uBAXkTy$H284YW74w7x#;Cm0`ev%}c` zmS#rDi8hN!NtstL&`&4JeJy7lK4NKewG+YPHTvxGy*C9%Veq2_6SpM6MuPFYGc%g;HYqVaF(Esc=YI`bAts=G=ikXV~H?DkSW=uc%v|#EzL(XE34=%2$!4|JFYJ2$O zm+$|IxO&@p+qHW>MA7B%Q`pr-u__=S(AL_Dh=2&tEYebuj}N}YIm8v4u(wDw8RFiy z;c1WyX+qV8#`>F^tDC;6@82ss@NnsCd$<9=6r~LbF)=W9^Y!z4M}xn*Hn+AsZD8v4 z$Mt;>rOUb?5<=Lpgxc^nog5epetdWw9Q;_L1jJ34C*+akk&%%-2f)(w(U@-)nP&|8 zKKha-#`?2kh7y@4!3=G<&?d_OdQJs;)bf}0?x++6`WhorRKEz|eWS0^GcXJd4n`s% zVmm5(-2yFjTE^fIwvW7f=Pg_g}L2M03m zn?wwZL`+W@zmtIRNG~_Xsr}XS@LrGsuQiXZJsFSU3_1@UkESaUC^FMisDQbA<5JfhHan>>R3ic|eD(BNva_P_*4eIJYJm}Rb^s=0zDYBwp zXj3IDNk5C;{j6*1Xzuj)Gkw_J*EVjsJb>)yG+8;VWOsVF?aYGJOP!V3lm%DD3}217 zi6G_g6F4mJOu1D1->);g$u2aoLm{J@M+=S?lXTROLQvu|kr_afk?d*yPsGswrP%zN zn$v#4%7BnHrw2dw| zknrc?-bQE#lf31&!+!O8yRsPXHrCD0(H=PIx{guFN%Kg7s#1bE8M3=RYSyTwrNJ}H z>&e}4+jxWFiII36Z*`CeF+18{3yu(@3vYY)B7?ei|c?rdpbSCD00pnc+F%dk9T>8H-{45nU9RoZJ z)eX(v0@T$VX>7h{j~t+Y4Zm2>Rp<6A`ww+OIx02Ci8CJ~D&gl|23OCW7|G=<)nid^ zWct2Asj0%9w8C7&!Z2@6*c*nUA>ES$4ZYf`G?Nolsq(6jTgWNgSm_wI&L^xr4)^Hn zw%?YMw52=-XmHFY#KqHA4Z&b?a-vSpV=9&Xq9SuxSn&@ZJ_K|Gym|9x{X%+3YSO{M z4z3SE2!vz-Ip~CFWaO^-!g*sQ>RG`eP!B{S7S!-5_)dVuucsrS;s|=HV4I+Tz~SNDTWAX|-LI!--Cl$-5}fdl`0uK)lt!-`k`g-=|_MfSRzF|ZaRdsd(VE_F)xTtPLb-(+7gBd>|(&s(& zLM~(*OM}+cbx#ltE)k$oic=OG%F3#oPWK2&D8vI15D`B&0rl<9&dyCEFUKtn4S=>Z zfP?bd!-KxA;vIUj`I5YTQf`OQUXs!)ppd zf4l-%(0@jjm-ZsUOoBk&e9l<>>wd^X*@Kyb^=77wgpo>4a-Wr<1lnmgaUCn#b_no+ z=xO*J33MMPC(l4~GSoQvxY1GhQKKY2{J4$wo;O}S&Jn1qNE-3f?@8ez)NEWtF!ibm zeuRKjsJO+-;Bz)hogD3m_6{lbj-BmnJ~Jjr^g4~tUM!9*FK+fe{JxkSx!5`QJ$Ui- zu(|p0`(XBAwdW&qR&6G0Lc?_)#PJy7sCgp?9%g<+q^=@G7c64~LvO~Z&Se6^k8^;p z;8bcvzu`??vD-Q7T+uT{Y;$(gm?zb|0hrI$hwKMHT7|-5ePVt-pYQA(_WCfl`82p( zXm2{%z<-+i)oTn=31M?)b#`=)?EUC~pP$|MrEzr(FjBnSe7Px)E%#@qKtlj_mKr$C zmD_vR23XeYkpK4{0owo{kE#8|RT!Vs%g3a!9v50-Wmp8RRPH>bwx(v77H#RaJG;pokv# z)d3;jXD4gie}@YQAg>P6!_rvJti#T|qLLgzl2bzXsN4gG9otxns|fj1cka%a5)DT! z?HBUlbfeLJZhP@l$m{C7M@ztoVJVIDh6HVv;-3c6Wd39=)26V?s!BL)fP7e)*IAl2 zSo*Ctx2U&t}9gF70Nmt?%V5Z5(9%DmvFKs{JZz9TX&OtgNpr?S`o1 zUoqgoX|>JCTfn?}mEoSfGp=qsc zt*oq!nh01EEG>)sR6|hS%CWIQ_+J6kQA_~re|VovrvUMr5I$zeXk2cvHDvdaVNwU! zYc}+zh4j@9E_L`%X@!HM^YO|4(H@kb(P^oR4l91$fS*R<@Uzh5_Ka zfmm^-SUKbfmec`MRSvP_Ze8EQ9!iCh6|j#tMJ!Uy0TuYI^9Ycq|ISVw*uGX zRtak4_g}{u)Q4@j@sozA`luAZ_T_+(_A}9+?~PUXfD#dC=@T989PEy45X06Y+p{dW zwB)$q#)9&Sn=LwVe_ay?oP;X53K;1B$%XIItc7kmY0VH12bZhq=BcvuBGD} zZy+D%RoQ4AF<4v_=|?z;9FioJfkdeb3Z)+)K0P27lRh(pQb3yGJ_3?^EO25ZxQRbN zf4+P}D;$fWi(VA&5+R8vwOQMeTVB?Rsvyll)sq+I%0}Q$hxc%yH@Brq9glC}6cz_; zX2e~lV>UhZ0NC_AL)hnMLozathK3Z0qME%B=b))AJBU|l)a1?wnaFRLvKqV!HX z?=SM98&^gu)_I%q%OxH5;DR1xttZt7#BO{I`KlHC6t$pi?__TD|HC(>(cqeY{vfcC z%jN!Jdz^SX!D9V)vApws^4{qwqABJgxx<3Jl6$@frAjb#55G|nbWCt8)Fadx0+m>S zMr|gW7Ao@_5N)1(*z4-@n%<&bzFuCS$NnsMl+U?xIe#VB@P^%)yxB8MurvBCp-5tJ zQJDc}&NyRiVHxpcx0O7-U84*yc-VSv$vHG2`i03^51^v09*hqidEH0N;Qc=4s|4tCi3U0AAb9`JlAg@dq%m1GZ)WVScO?ZRdfNHS!LF@lx+Hspd2B2-Y$z`t4<)#Q+wg7e@0 zfry12;7KUXz@scUyD>`WqsTvv?>SB5o)wo|oaK20I;G zFwqsMFQ!O3e^I>Juvu*|VO1pZ=sSu@4bNhMjwnHCLIyPWuo-ln&Ojnl`NM&bQxPzVo9{N-_9PZW^cdbH5sA0wV3aZBcKNhNPs#j~1bGh}VhrE+Q=e zXeSNf+#6sJL8ImmQ#ul9szR*Z+`u5)+SxeR+Ll#U*XgjXf4fBCe{L_oz-%xV1WXxA zf&u{3525G6k0AV^SXu)Y!1gdK`Ub`h)NcoDeb8)`kk>GfM9wYEj+B}9Cr4iZQn{TS zj*bon(>y#ptm=reKK|<2THoBSffx^{L@USImvKIiZU*q=n%7~ogYqP7ZOKDe2 zO)>|YFV4d14J$ijEx*;{a;&m!VZS)N7$~kSs5MQ&dv>R!B!u~-ZT>#)?Oos@)4Gr` ztL$Fxgz7Zus-709I~IQPcow3RLOZmqNfixl9?hvA_oNe0@f;2Tnh`}cjiAc1%8rK0 z8W482g%CEs0IvJvA`rdw`(X26F?O_}rmgOc0LBg{QQOSy;xy+ppS!J%gTIZtgUi*( zUcWi!iubMgY2eYu5D<2BbA81-&9g2cLCxTl z4CGZB-U<-pqW;h|)W0S>`mV+Vo|*kU{d;bOZ(2lXo^N_))zEk?n1K%`W<4a&*eF{! z*Kk3n@63WeK&nz|$fW1wCd7=ixB*#_kYQ|JenLr@Znily(C z0y=MKa5&W65Ca{svRIC`go7?!UNUvED0M5Wz|4jXnrG-dM77jsd1DCQ?o39`PNRI56^d-*m9;=z=J2Ln%)j< z_o&+&`NLU;)~v0=P&lkZi;pZRKD4n_q|%M3wc{O>Gv3+Ia_D;OWHRWu=E*@E|9$g4 z-FFV2>e4a;0bI;eisQE155OihrPINkliB>BSK2@^q{^y_0~;qqYmr$#;Z?pw-+F8R zZ`SVL?A=T4ElS8CI6|msInRM>H{h@2_ND{_U~i z#~dJvnVk)e9pqLRpiEHz+{^{BNC8SXK!*XyFjlPUG9k6BG^BEjPZ_}hR`I~NN{1W& z46&WOda9)pSTx2ZD9AQ4eRVbc>+099UsqS$nHLa<;#$aC;+UO}LK*E?{M(&OC*cGe z_!Ug}(SJTA1e&r-AV$--n*2!cuGOlDM`C3+}mPx(978I)g%DcijTH4wHI_g!Up*R91tAZL909TTTXgB}@%w5A7M zY@ce3f>~cp617U?+4XU^J?<}BT>#QucF|5eaB0y#>Q_@OV?H1mZH0;}PGzzV17>Hy z)3TMp{^99N92gRO_|TC#XH{p~sw33cR(H5K-wku?c`F7|Vb3Xw#+)k1u`Q(>;S@2X zUx52qh?7=cSi+TMfg;V2I=YLi8A1fY<)JApbKnFnDh3*cffX_AYi$RC4rK_2@ zcPXf|$Ehi-g&|yR|R4v@n$4(@K1YY6ykZ97_}V8qK5X` z(&T$yZW!V~3!BpKMRlExT3^1o(~|*dW;HsTStGl6V@7sbvbTm(1v2*)r3j2y9p}>tb&DXRlPRPG;bqMgJ*p`A0PJu z+g(UB`O6~;T9~FmW=OUozOh;K{h|6j9dWqVncTUD)$^u=o%&N!Y#Y)m2;G%iIyL z06_OO9RlOn_XK>}yf?g4MA@j4kx!e|uDiQmTC+Po9S%?mG-F>|HH z4iCdXC!pG+!o_)DwTgpSHR)C2q{lE$fU{A?bXB|g!11Pfj2bnVgtBzHyjj0WX-9nE zO5Wi)-T#8)6y^42C@AjP&+cBx`IIsFjZl%1z9(XWFG zuJZsRa`CRQ|MiTv=x6{c_{H8mB*VIu6gsgvJi*qb|9?aV^7odG5Q{Py*17F@k5 z273+W`|B@x7y|@1cHI;*v^XsW7~2G74=Jk!3t&8q{NsZ%P?s zP!bK8G9faVvMClNs+yMNjM1^6=d8 z4F`rU%{3%e424aNIgQGw-ULo`y?~?0K-9Z=I0cT=ui1Kia3*TaZR`BD^KnIax%r)s zkC}xx5Zi`CNJNy=?7ux3MG>&pwsslf1@Ko<&IUv|drpU?6{S#lV}v3?NLcJl5%$nA zDj3YN5`bGerPLB>uULXQ93R2vtoQD%riP{l-wbaPUz1m(XFYFY@BpxV2f!+FGSaVv zIqd@HKgG4HL|l&t6QtL!`^`^%Uj6cY4+6%8E?2Er-L{71#kYw~NL zkD$`dPUyq9ul)g0(BptkJmhV%!n*{lPTpxKP7MQEP+bQIt;24XCeq1!c< zi*^;&NfQ4bgTMa>BSXGEqY<+F4OReRFv32EFaQyuTwm|e4k(xiCj_763uh+^pB#(D|H~Pe)A?5FsN9sresty#-L5(YA$&ySoRcA-G#` zmjJ=tU4y&32X_q+2u^Sb?oM!PT!XvLpZngOs;Qbs6;xL@MZ@3pIs5Fr*0(;IReLxT zY<#Ug7wN_*b0O|q1e?tRbI8u7`p&j(ZT;KIDoBTeHp@O2@Ga?wf6V|`%%IT{FPea) z*kIB7Q=bm_jgW4CY9gQugxAmr$hI3wTncQBfU)v-pO)XnzZ=Sn3)_IB^+$aMUW1vXtg{l{EE;(!_6ySP1n#X2r5ej#zS#2hT+pk#i6MTp>$zhNLfu z=evEOAs86uYls82l%PT=Baz4b-p_sNGW1WNN$LkRZEjgs%;JYrfDr_Dh5FSfHe5T* zMb2EdNR94;xm-UBjFCkzvfZQ9*KbA!P7z6rBlV23z6hEw$PQ2*6-9xY>v!JinrR37 z7G;*D2L~35Uza@GKYyl-E>f0d&t~HO_(ME<-lidgiMD}o##wl~C{$Sv14)u1B?pg; z!4dadj}}IQM56h73t|DKLS+U9kj@hASvYcVwXDbd5h)VT)1^=CJc^_am4c#aM@q9+ zvVegD&^iPr4}*h)qZM+j%k|Vf`4tsExKORlE@G&Mw{anY`wt|@3g{y9BWX(j<%o$A z{Z(AuT1W*OPH=xJv?as}C0LdKLxp1vsrLw@no6QbEzKNo1IycvO<#ZksAE)Alzcj1 z%3(o8K{3L0Zzs*1`p&VXymWF~U0ubOd(vZsz}LMz*J+|57D@Q4bHae)O{+?SL6#t6 z@_Sv`QkN6R6)TRxD-8UHid>9l*{n_rV6Xu*^y<%_g>I(B69h-c0Ca-$(X6M7swz;I z`cmE#0D%oKph7Dw%ugQBM`Ya?oe{`S>IBH>hoXKI(NZZZj0>gVgB$T@--5PAW}+f! ztA#k(yl!VxM=qmA#x$$HDhRng9*YB|QaB4LfIDjXHLrGM$R#4idvmIcS(*_o_Z=b= z59VUEBuqsP1Vt5=g@ZH+afi@tRe=>H>#i#)FFq!60NyJc(;TYS?{xjUvKK*?<@vA` zB~8|KgRgPAmM*W(&$lL0v;pITgBU5e-G@8Pp(wAuYIe7H9=mOjL6 zNJRGG?8EWNk)y3cO-1EkNR}L(__yh;nJr^5#%dYnz7sD%{DFIh8-gDSe-4lEhJ$Z= zdKP#N_lAGGM@5N8y+QrO)y&7h;NnH&jdi^<|8mHi`q{R^Q$+-(I}^94iiP+j3_^tU zKZ}d4nTU7X8+`d{Z{ij<%vW34%Y-)D~ySafZ=rb7hZ~m>xaI9#i9>ljf2< z_PvFG-yDnoj@951A8}`L#<2~iB$Kxg-fd%f9mJ5YdMWM94jLc`cz*0$Zu9by>x)j z$VnwwI3)#t1~Tjn7dganSOMn{GV$q0OaTbSU#5IoOdiD;$6F5~G$xSGRlYMhdIn|A z*=Wwm6x`kS?E+x-Z};{NK0WpH_=9j@&SS0@W>@%Gd0B;o+;5L}|Dj1ze z!(fBx!tU;->^H!DLgvX3jhTZM0fVh^==&Q~x{aO0+ zM@wISUfFPSeVvbWdUJlh{P*YWSirm+C=A|khKBqt{7nWF91?*|JIMrcZ=cXm*%GN+ zw)322V@MzCVaGv&k52$7X586S661^vX1G3@Dk>`a`f=$g{7w%5A=T}+v9S?NI&9vA zEz_wqjGi7x4g}yWYE)Vve~(Ta!uEy6r&WTOhFfG)D4`Jt!{w-}FE{K)Nk7mU zn1`*fk|oSWA);)sR3Jyy&=gCEmf{+W7>pUt%`04mt;nURp&}$%3tKla&Xn&JfS}4O zxX9=qI*?gX5FKN%iQDZ?Fpg<>qKKXNqZ9t#*%xS;%UY%Xp7%TjC2-?;u?T?Wy?fdY z+CKS_q};N1OuCG1$6?ArNFlz@bdPW{vj+FNa!YcLSj1>|o75Lj$kS%ZNX-1PBYW{< zNQ;WV{#Kl9YIx{$Zsqvwq|18$vhzS}bl5Woos+le95lJ<(tfv={hY@H?mppLx+8Ho zf4FythKC=D@K?6}X2gpZPiqq8N(oFTw9CPUg=@1jE6aMDI@UEBYJaIQKOvB{+;={| zy}EMSH)dp?5c>%D48lv*#Oh@dWDuMZ9NHXLx&XEhjSKbY(%qEG_>L@9Q9kR6@LovHZ6@+h?az7+!5MxVa(06b9>j4eHbi&{D1pIR>?QLDOj)yOm0sH$DCPZY zq?UPKA#_3_h)GC9(J}TM`IM_{1XOWjILGJ;D+Uh~)YyTPZ8Y1L64dL8tRj$8OpG;A znBjO_4}r*!K?1zI&t>uP@edy0sj2q$Gbd0eML`_E(*P*@H%q1ood=)JU%TOjF2*^g zd=EeP9lo9iycaKiASEM{s#$l~Nmh~jae+KVqNQ0E6$K%L&nZa-b*mnHj9Sis$#ns&! zmrZ*5feH~X;Su@`&=GWYc4{%kLr?w9B!d=ZFhkVtL9x*Dyll{6!i&MJk4+bnL!{|Y zUh@js^diO|2!qWHZvKk+Dd>|ZJtz5BWigc&wGI#r5|d~cGE*-T2;Z{}p>OOP0C3V< z{4>9TXz+48yk~2wNk-Jp&IiPMyF=S1_Q%%-NM6O12u>@a2Y0qPgDP|8j5U`NgHdqp|w4(gGQcm`acUNtz!axUZq&hr$d(z^%xgJ)W+Ydae`93(#`!H_aB}mJMLt=irUq$GFe1Ky4gBJC&%_09}8iA z2AU=_YHEEXvLOOALv1Sp$F{bL77axysfu2`q+O z?0+JxBncClzYw)8Wyb`h%?JNLPgL%^|tu zN)S67R4`Vwm=l$E5H1WHszb^#F6=T?;9fBZCO4Kn)e1@V+D52szd?>USEASO znA+sXlM`29D>CnU(Tv$CHK91E{D5gWj5I&yflM$OJZ#=*$=rXe(DbbxNt$ZY9}Dh( zFlK)wO^v&aW4dKh$5l_FF5}2lW+Xu%g}jvLHDO=Wn0MuFzHhomaYealy8Xq~G_T3S zYnO_*OsRq>%qQB|YtNlOxjXyg+UE2870L_70D~Kb$`9Ejds^MpP9!rWTR&SRT@Pt_ zX?Z`Fuqr5FM$@RJue%Q*XOeiy+02yEElW1z7fafj7PTd)ja{3%k3}X$c*!_FT`F7@ zjl*`K#U#|RrZUogiFL;p;Sa(ppW@)tGx%SYwiHi@Mo|Z$bL%o`#0;3QXBrh$*l?%M zWIkANX5nqp+9&Z@+KC05$)>puhbpV9&t~4pQez>Iz$S=iD&T4NUZP!$NJ&e>!^5|?w`c2S0dhA5S`K0K7xIhT#SFN7 zD+(;qB^s%l-*uvY+(Ng}dnBVkDawHtF~dpy>9*Ws>=Lp!*sSS*^a`+L06I3H<*%fd z*}=}|wKtlIsHaKmTUK zNKSsp!4RD^XaJTS-RC4gU@|14$Qp!T=7@_*WrK3p2rL9PFbI7x_q21jz@If-C0NOC z<~iKSQ4RaFIkIAmc((TX`hZwk$gQ$Y!5A=7@&$7W84+|1+uG(=;&Gn$?Ty% z06Lcuv*u+$TFA|9)Dq^>hDAHrL`M3d&)>(A&KYaO;;R)`FRM%u0g_QI^%$0wH#W1( zVV{bcdg|po#b+|MV!^|9mJL4xpFtc{h-;jxX{czXwN_*+kR2-*j#zK5c+!s%YF(xU zx%T4f3OH9cRX;ydz-SC~0)gpwNpY_YSGJjKk!LFNSujFILbx)JXM>N8y90~$`!WWD zOro$fRvAiJ<>1<@0}d`Ys4q~Mgs%tL0}x(G+e*9maTxH~{!g}RFar+%QZ5O9BT~Y2wpNQG~fUxIw4-s)$!p6us-bqD*{=>*)|mw zj20X;{lGw@qQ;fCf&oxwGPGiaAQpy>s&QwJ^Fp}I%gDJ&hV|KT8~gG0MH==UnaAdR z4E+*2An9~OF2(kbPUU7H;+^XrzO0)C+1L7ilzba}ghpXJjW@U_=Zv_vgCNKYGISd> zz-oGEF81zi6FdJkz;s8`OP&&$B3jqPql|P zTFAUr3FU>EN2X?ggvH3_JG|d$-+gOIvnFh?Mfqkh>u7P z9rkt0<9ImLUwKKuVBM3|tdneei}tL=;H~MdspbP=1j*jW+VZZt-wlqwM!wD$qgGQ^ z(&Co(lDXRZ-|0Q2Ls39-%-M=`Ntd4NSuz70ttq|Eu|JF)%O!q*d}m-7>nqq;UB($R zZ9m$;tvpG!g^O(PaC2%bee5>R>o1Dv5dmB!SP2K$3{)wHh%>etHlhzR5D_#$EsEpV zOOY>zK*Picq3_WY-iE-Kq$~#n0Rdr^(H9c*skP#0i3X{u1ubT>a8;>tw0&C_8*e?6 zll~xr$pf6^5fnKTU3<7kizMYt#exYC7I9oCumJ#cW}q!fnFrG#i#Qp!Y=OP`+2AOV zI_iSbf+j-+B0${)yy$Y|%ATpvZ&GK*9#virq?@FLA~Mj_003!a5Kfc?psDZ>kEE96 zrEnKmk|mBWpuR30u;BtMjaZT@0Ly?9Gl440j6Hu!C8}sFv_O$PjbeoTVbP4z3k#C| zgJNkCxPoii6Xts!TDWQQgxOBrk}Ne8&yfONdqR+mnve9sGRe zwqf-$5R#iX00y(R0(7LtkEv8(#%?V+f^R~OfXD(FnZ8#KP_FUNB#oeb2o9BRBT(6l zbJs9V1|PAEilq7_s7MYM2zHLZpWVbgw=7z|DhpBZ^STD`D)AOpRoRDxg#2SayFPvq z7l*#XS$On)J3RCR)Vb~!4nWRIO2;5)QCa+#>~-LjzP7$Pw=rYv!5c}fI+8MB0qy?| z=#uA-*4EdfBpz7ISi^q@;YZ+}Vg1Gq?ZKf*xJEQ{;Zxk{F|`q?M8)mDZqf4Ds8}iu zh1lLMr?f;WkqA7NykV2Vi34*X3|lkSu>Ijv94m(yvz9QHkUaIAM4%xFO^+!GM%z$! zfqpgdooO46K!S&bF0C`;Z#@SnQufbKZ)yzoY`Xlq{B5gkz_Nl_SX9yKD>M`U`7s^8 z5%5gl2GOI0uxOF>fE2>BW`h+F>MMWf7^q)ol@=`8H1XTTXN}rSU!eJf#ilzB_LX{) zQIlYB7$AXCr;Ad2D1J;QQosDF)3&o{kloIGvc<%t#c`mQ@YeA}#;4FzveH%rX=43P zj$RE4PF}T-8}U=KHe9UJ{~@@z=(dD#oY{J1czeUxpx!>W*g|DO1FBgN%XTOEr8{Oo za-`fZg)Xg-=7bFlB($w3SLShNIEf^fiw&%zox<&4S4WJ?H|?2d9ArbrHm9A}7mZW4 zGa!g+7li;i4y9v`V|sJ*9YEjy{%5c@pLP^})Vt91eqo(MIGB`}=#I7OxL}q;6j8=j zBMHTSfdIXohTs;ws}-OOdL}eo%*B7vhH3y6a*rg*ma<1sk0qgWN=U|~g~p7rBWdv@ zG6f>~##n`frKxbU2(aA|{Oq?l;@$3NvRw^g*aJh*j{n!8%bEWsPiMD4;VwYU(>X zP98F9<%baX?2#3{xj)NG8^EDQYg1WUTNXjlVzw|@=7O3;p2nF$3-sc-M@ZSh`tmn$ z^FvNd9&#AgDN$IGVGHI)+UZ?1AaY-5T3Bh*sa*kdn}cW(V%f}#SF^J~l-k|h=Zgp4 zRJpzDB}mDU1JRRU{QGD09zW(9#;U5S)=wKEU{LC$GDAphO=SgK-1_vAvZ^jqwpp+s zrWDQSJA*h)Fe?HeC$8DWE;nPvx$zVX*Vk`71p!+tg8G~q15!@e zmk|+i@X9U?0SZkry(DbSBmx?=Fib`cYg~Cnu+cx{4J~N!H6g2EBls{%ov^}F1 zhOg*56gXgFZM9o6t76TajagL627?zjU~)+?ivX?s5cvbRD4m^EKvdF5PvJmF>gN)y zOHYQ(@xA(3YuX08IsobsGj9VFZ$P`Qrs^jUR-b|L%N0a$_2_G0_c-Ywm?wgwumjlp zLuQ2e`BzqYHGC4{Lki3gzev?G8W&a6nuYOQv^DI`(H417`5otvL``&$UaIK4PIIy(C4=dN3b`xng|?k_CNVoesh`1lBb{AXZT z_%AFQmUVP=pPrusfyu*h1;fwzq^W~CMeqYqcLDe8l8Kq zZwh%5#SszyX3)H7;U-ZqSAlL=Vwk*!6g||t3ZiS>e4i>Gb(q;1D{)OB2nyiHAuuKb zi=2G&*yF4rs2EIj5Q$U?Rg`U?PKbSR0(q5!v=DqD6mSsur)Ob1rIy4ngfI|QxVW;X zq)agHt1W#T>4*|^suoJ02IDQ>s;J#{VPiQ_3AdFmXLSRkJs}2*w?#b;i2vIQ_`Ic@ z02ho_8Ee97Ncx*X%-!2UZFiM=Oe3bGzNlz+tD7T|mbQ0f%8a92fj?8R2&;7d z7P#~1?N7k*142b1Fb74VYnic)@5BuxN>WHs)5a|ysDg=_ScffQisn6XnSqbzX7T@lm)|6O23 zbsUT$K31M1J=*js`ifvrV?8Kz39|?`ICOq9N?pZ&g*s9I1H-rl_D!oEck)Cp;)3kw z06RSY9QNJISS)V{l{IKejo>W97dQbO0AGmq;|yL7L1Z)rq4oP5vS3hc-BDIn zUMmMzL){iXztHD0z8welOp;Wz$z`5D0Au-RoK2hpCJ!<=M1p>cgl|R-HAfdj0QGvV zrwi>y6gPO0PN--aT@>19yt%bCd3H8A$sBp7K&wcfN&|o=gUcKpZ0zi;0#UYWo0_T% z>maFr4cO5Z1qR)D3w|!c{uUBHvEQx5A*nuhB4TZ2n`vxcT-omBoiD^U!C z*ITO@iHN#-ayd0OEYRIot=aIsUHGdD*xktzo~em?^a#)>JAiEP^76v!E;16KaB^Ha zI?Rh@+n>V|-XI(tpNjH|v=IxjZ$QP{1+=oq{vwi)mT_qJa=<}l_Y_uK_IfgWV}N^C zV9_$vDVaz;c+EJ*-qcdoZfWM(QqzpjDVSA1@*RY9b$(4+ zUE>H~aL?_|zt36A7F{?tY1!D?3aqTQ@vo}zY=`PHB1o&N*-@d*zr4HvI+MwX3BFug zpZ4}82{UrM7%5Y_Z^^@jo4@zIvM@3;GqHTw2`!t=h$+M3*Z!~Dyc>6e;({<_ zf~L_Z2u>zt2zcnlknAGn&jlgHB?h_kCPL6f?|BLaWHhCUA8*ZN5llYT03mDk#Sr6I zQfc{-0&Sj5sL7TQr*Bf^_6MC5W$mP|{sWsK7e3dCR#nZyYl!0s{KpUFGoAo{nko?U@JHl z_Clw_l$Ntr+|tI2Y{>*Y=nc@PZV78w{{s3G;QV==DGl%}s&W)N zcClZyic*}Mz8NO=_$x;$z>13Y1g}=z5lOq5DvFD%clLcTP8-MiMp#xzu;t5V#|L^b z;@o4G%6rSf5azq+6N?zkD1}^E?&W&f*577s3u98H5HEvfFBnr`TH||^#%N~D#;nzD z$}npf%qTkg<;R~sWurioVig+1uBOJyPLkMzu9CzQj}FP1_MgBOo}ct2^p*& z9XS&LW|6we7LE|Et0|`kI14b;U*zEyS#hvy%Q-oj z76-WHe_qy#hT5^w@^`Pib_r6ngr}2)OdJ!EVg&^QfTsIgWzNDnHoEAIdw`s&~Csr6h^PhcFNudQvM zrMI@S0(D)rubzViWq=XDf>jnwp^pW5Pxm0oL7Qsc^`2E)!f|B9=l3X>4k)a()51JJsua12@mP|INO@d;;;X`HOsM?e?y$^>uh<^4Nfrw4(w zCINxf3&hjEe?tVO%w{bBZdg@SLj$sHWqA+TGOI@ASAaw4XKp^wI*8ra-X4*Nv3hoP zwm@rnbAXR`rl7dq;pTEY2_*g&d9220t0@-7F4;9bg+JZJ-`(9URE)|=6zyKuS=l*y zIGTHz`#bpi`K|BV8X*)duQ=CKR~u7;Jnrv$zaiYAiG!}fORTfPn@Yw01nC_=yf@sJ zv8B;yXA!thb~@hP=RIHhZQe88`HfVb9Xs5~0C+u-V2E$z)FMzcZ&oG{@gNKg5mEAY z*>`GZa-$!HMZDs-R^AmAwj<-Cm9gWI!%LbT=$rR`9s#SJiw~rL)H~p83o}zaRa<`H z)8)G9P&_GzfP3}&#P~XN4~!lmG^`%9Q&m5wNC5n~ z`Om5otMfx(AaQzH`LhN&LcGb;w5__bGB!50)AP;O#Mi{n{uK=FuCG*P)ye`%P6N#J zwswACC1yz8%U~}xwqVZ$TFKvLW@^X;Js&P8?#?kkpkS(=-JUGg7>tegczzJ_y_wfU z&-?9JSJxV3V5d7c97-QDlRd7m{Si@A0*YC=C>$bAB4EEj}VHj9)*Oal3P;X{4uX zrr)Cj8fPa!)9YgDqHV1WA`p{sdREWStwsj_kPhV&BOfWUbUpp*S|}j7tQ*M2VFMM( zC80$G7a_itq$P;?(XHl2kGs9089AU7zqAbB0&g)*qj{0Xy@|VtN0Vm}fJ$4Y8pP7JYW|FSV>N_e)(0c-d<|1_YrxX(O#`7F>{K&Rc$?_vmO3uT?&&EBXTVh za;@H|wg%eIFJ5LOGjRd;g z6G_p-v9W#>Kq_||p)v{*D7=)hv-u0}q(p(>M>_gv(F3Ex%zNe=Z%4{A(`%&i1buEF zmScvL428VzzC6s~E9G^1UfsZ^R6{({b50oq3&RGyZ695yGv|E@p!dAnUUmUN9vTYz zMVs#(B;*SPyzD*}4=H^R^1nNrX^5Z{ysf7c_IWrw>XTL?6ZCzV50Or&t9|n}v#@aR z@Sw{cV;EYGz{sxy-{N&Bw+$G;NojR`H1q9A2>hQ5wzruzHYX94*3RbamS{Jf;^zWYrs&3@<8G~Cg#_p}o}Q=zQ2_#gx< zRxWlpmdN+s9@rxs$iePaSjUgZ69u3TQ(~Vq>6U{tJz=@n*dTbeJU}=VKY(!NpF?PR z_KJ%2m5c-^+G)9;OSrkY5B3jgWfSx#+V9T-9tYM1Ji1=4#(WGn+5_%4I;9ix0EpBS z3!{UBT?zV})hcJK1m(=y+Spa&u0rzopYiroLtzEZ}_YFbz!adh|*!}(4r+_zKXJ=xq%-rYwjNk}sb z4x@0YEi1(8bp-bSHgwWGv)QAGiHXNY*#6xS;P)UC_I`P|ES63<+vt4WJxVSwr$2@H zaQb1XspP7Pq8HYvUzr%rbV`UMe22nym!To^bRq4f^m+IM^OS0{-2j-*Y)m^svWDacdWg42c?kz&BIKeG`eWQ z2Vws(($_j3gmb_Ta(ryVkehV<8R=D1Ero}hi<|S0DIK=DY`8676B)Gd!^G#&s7o1mEC@`@h2&`)kU8zPh@aC) zal9^E{4fNDDw6nvTn5Pi)Dt8~cx0HAi5Ch% zHvqdPjpmFmrw15R1^&lbiX=KOz*bMnHMxN5|}hN zHnO&dZQogK7w}XDo7DU)|5;u^A0?5wCR0ENu@3=5L;Sdc%~?S%(*VMf(fC4$XU0Tk zYK7^f{v+N>x@bm%Q3XKqs%z`(=!}y2aijjX7Z45f&GFOmhRQZNhyq@{ZCqU~e0?39 zef@1*Ew<^zXoytpH+GllXXMmk?8-sK>ueU`Yj9d!XL1N*&X4HX~qVj&DfV!YD{34fpYd{ z0~rcc5QKu(!aWQ{TLICASAQNF#Ral{%!RX(fJV)#uA{#j_Erbe#7L2_l6$e&GsoU1 zY5AZYIk=LVFfy`6);>^lw?$X}G%XYrUr;ks5AuDQqzn`>m6WE8Yb0h|`Jp$tvc5hP z{<=e_biVO=JO;eUM;Y!kJ?hWH@35^>C z+%wy(x1Ts$Z;>sao^NypDD6IDz$Bh;JYSvp-8}E5d;D(kxj*X!K7k+h<|m_7GLUq# zKiHN!Ho~9Z3m4i4-mQ{sl~bT1L=2K$V`Y`y1ygEYA<=rtYzY5%69ac*T3J~=Byg&$ zsdX$~=z{t`LSeQWDUo9Z?M{fQ$!PP%_gFCMe9>2#pIQ4_{k|6VhP=-7NpW7Wgr=>LJ$IGBQ9SrnD%|VTU`94z&VJ2siy}T3l9PYKM_(>V-$1wTPKe?`Ovp?!e;iA zTJp3U{}Poev@lItZo+dZv!&4{lCgTG)jAS*R2BYY0|Uc6?W}Bryxi3c-BsOn`SVhj z@|@9?QRiXzls<)}s;JPuORY*ym9)roDXJMGh}9BWsYBj)F+Ch!G&4KhWxk9vo@E$|tDDgq()PaHQ3Og4)*07Eew(#(^o~6srw03xi1x zf~Xo9TAnye+?A9yhKqE)QkM3Y*&MY;Kz9$~X0A8{2@oLBChq|XH=uq7jGI;VOaQQ8 z>6CigjvG*W3vu7=Z@fMO83uI1j}iH=htlsC#UI}LgkSomgq{XiKKS2S**II+yHO>N zChmlWh^qCI0nNbk?(RT`vT;sDLY&snct}t`8})dSB;)MRl)l${iS=J8kUsUY8+=L$9TbQ-7vY(a)oIcW;7TWkv z#|EQh&JUP6Juj~sri7jW=nvG(?vU`~ky5}DfHehkS$laA7MoglM&3JwQ#m1#G$@a( zA%_LCjc3-+UR?o&QRVmVFkwrF>q|@hX~G7r{+E|S!mp1&aA8dT`^|yyGjQ{6{`J|V zp`rXwA(q(tcaot&*MSShvAVCBi75qN=MW;y#Y`3(s5P+ABEaIz>H3C-**RIh7vLTi z|F`Q8?}tOe@5@tN4=_9_w|K^ zm6`dde>mvc{0YYA0w9ly#h~lu^gmZM|8srv7uC4ja+?};mihI({pnnJ+x!V zGcx_GP)$Gye+u-zKl$CAUQe9^e<9ub#a#ZY)>L=?+o|R6mpzsbLY}<*{B8C1vC75e zM&txY5#}Z-X{l+ny<4o4P&ZFcYN7HdZ*1Qnv=QW@^*UvSzXZjbBcx{Z2tp>A6+%u| z{6OpvO>%^xWGki|jnK+Uo?OSKP9iA8*~VNfnfAQq29jC++9^#aO)-xDEmMsYH z)gPjq0jltpjtiU9+Yn3iGoj9a`(@301HqR2)2(!+^Gz?`_pKN@RTmCj0Re8llBn60 z%w9d+OUWo6wGKcuub6nAu;0yJ|sKjCO$GRmpFv~8%kY9cbw zkK40aYT7GGWJ0Lk*hw?jg$)J`i(0PsG6@_kFE^cSiXlb9$G>vvEyu%4BZHyBZqa)A zp}-)h+B-;Jp0XZCbxNZ|dr&qE+Hk!yFf;*TArkThpRdNA6UaV!-(Jl>5I>GptE#GM znJoAM)HJxUX^uI-i{HS((a^xrD;XHME5pIT@ri>eV}X-{i%TGEWsU<3d`DJC+Yio9 z0zch)%ZzE&ElVvdW>&L#9BT<)=QRyK`CYj>`j7r_rZq2}wp0bV7>quvdItdSXWcPlDZioJX9$ zMdgA>Iw10oP*h%uqDUghalu6VEG+z*saGZ!&1ql$ONP+PBI^VdRAkAmGA4``O@m>;z{Vl zieeRj+Opn?8*Q$6o%=NurxWt9XAk3w6@>~bDkU|Qi<{fk&CS)lX>oBem2M34V+IKs zS*4>L(4pz@;&^+yJsi`lG3@mI#LvzxBve;hTiehOl53Yi@oR5yZwce?;pyS&sg=Eb zc|~QkR1xpjFf@u5r{Z`@KZdUsVt`{~wFpp$I1X+Wgy6H(+P>KM+W8v$8~a$U)>kQm zQW!t}tf@IaK07-;6O(r0#X{`!IwhmSDp6OTXJTT~2asET?dRv!X=rFN93mp)a`H z$={p^8UQatRaFfI5esmBzr$(ooRX3<*r{@KvBhqzEhHAt?t$Pag1Uwxk(p(uA1_ko z4gKUJQQ1@q=X20v4Knd;2WZ*?JUr~|Psj5obu(<7?Ojdbs(}k~a&ol|8!Ia->vM4k zJe+^1pn^m&zOJsV^-Dc3EiHZY$e7CO^nJuyJ}S3HdA|T-bdd)V*vWDdAsJnX7eWO= z+#UV2waLoX<#!rg(E)(_PH|vjb4L&K0~23g64WlwAeT$5ui3e|%RpdTSnx#$wMKA@ zG5NTaTzJ?FB*8ULs9G}g{*MR&m{3y@iWLhf25*)iD~z1VpH+QF4)(SK-v^H3C5qEb zKah}B+gDC8jmIg)Qn>G<@Z0)QqMy(HPucsBU6R7jHP$gYEWO`5{Pn2EkMFPyXTItMT*ywmJUa z1=hEQ3XERO3-u6fL5pgENl?;cUN)QA*LNezfJ%`(V%~sP06W~ON0B{Z&c3X`pZ;0g z>6>C1Y$oT8HvNE(DSxu8<*W!p9G+_V4TV#N6h8d~1qn$h(0ES7B0l^?GG#NSaLnqH z5!@`oof?d?`{ihjf?WICHzp~5`w|Z3+D)4S=ccfExv@!Wtu)gy{<=yhV?`P6Q^^!) z?f#)54?M_9@URe%dwc4RQQr9Eq;ccjUQ9aj^))Zu-bnpmIIxy2#NWBw&AYk-@L6El zF}5e{<~P=CuH^9PIN^0Wv1?{Ny+a^W65F;gg4ND;6NQ#(YA-7*t0`+ne@aW^W<2Uu zVQexf!HsEhak&;))g7%vahDl+r|GFTJ`X$@;9Y`Z_n@9(fKeW6S<}M&Xm?<`HvRNJ zy`sPmLB7BOY-J#rwgFK1g`cP1I4(?A&IS8mM?Glnoo3IK;Y@!=g>eun&=QMsU?2~S z$}_CktTNvjw7BoDG;aOGf0}gTa;G&NDL~>Uq)TsdF3(9FS>}UZ{d1L*D1?&u{V@dL z=oXc%NFq@{-!^{6p7wl%V|-#%?zKj#ZR>y8k5u7K0o{QbxuYu~(rkaZ0;T6W-t*Cqe0}lQ)@(pS(NaseXIRe+Li7orw2A z-^QUv&3>`->OIh#Asl>y2duGk7{ls6Vr>6~r-?nQe zb3Y~ECrK2c5Ff{XITU;HK=ob;algJ>;zh@eE;e3HAjDP|i^UlF`6OaYK1MYeyV#U!C)qJgGYutV z=1(R^-vM*Z-?P7IsBUu-RC0V0fgZsm9f^E+Y|_Ovfc0fPS6{Qnpw;t8Ok7-?5idk2 zeS(yX1gJh8?CnisT+hMexg!i=su6)IfHcD}U=uK#`#&hkFI4WPdtZ$8CbA4~xOIkd^mhfVl-=G$KIA6oP-k@iF~_gaT_V>Y~@J4D(~WA4Y}^uAEvj4aEd?xEjJCBiy)q zMP-x?rI)m&y4t@W06aQBcEFNTRZ-3z0;vZsKi%q`eVMcT*WhI3;%EfYzC1l6!^L}? ztcxm(JFfvML&W>p`>EsGjPTRRPt6)&lmKLzN*AfXgc2@>JKL=edT35UEw_x4O8c_X zDDUmzGF35IPUW`0ZvFlJmj9yrKKc7uxYO;Kzv)7}kX-LemXkdNPBd;QMhm>vCk$h5 zpFot4s5JE?1Rt;kjkmsMI^lf9#56ORH8SZ=-Q2!$5R>8jwVFjydjV~j<^`HD;xks5t={1Hzes_mQ z4MR23hKdsk0F@rR%RSGNLrT!)b-9wtkG5bZ-@CiN*VND~E3+J=E2FJt z;1c@8$M*@yFXZz=QB79eU=fUgc>I)j{J4E2`^aUzg+W_dN>}=w<`)}V6mD&DAbN0A z`B(fM)t#L=Eg<&Sk+yUrR;e>HbTuQC+!0^7bL;8C-Q7cW)WMV?Fs5b)9JYNI)8IXM ze8AS~`+7W2yjZ=_;tyEyZFwB^+TC^rtFn1QW1L`f*jM;whvJp_pPJs!PXP09%NQwrNPWfJDz~cJ z)Z6(I^${&?;5?9R90GM(K>ID^FEQkX2s!_#3KUJKwESDS^R{tOWO-x?osS&LWeP|l z#%h&&I5L!~PDYj!2bBm?T&nv_$`Pf#oMy&}*NjMu#@$)N0WgM|`j}d~SzCBnnE0F6 zx!ak!@7|7|EOJm$xfEl|C(r%lxIRH1Tm5;$D!8_+cz$MHd)?9O*V5V4T=%P!y@CDN z!r|ri=5TC8lZDnGULoQ0xMHP4B^k-t$Gy+^5%VAWaTyw{{ik7*D^@A7P7Z2uZc#!`(v09-u!%ja`99u{*j zmHT0E-_Owyz3Ez4C)?)4wr)sUU0qvSUtM2Uby0b7c41|Cepz_h!@~2S_t6EP#h}CK z4T^j5Z=2L_#@DS5&kk5>c8_>=c{x7;SSt%6Z)R-v+`+zps<5`cUSnCESc56I&K z7A{_2cZBSYNGcMTw2>Z0%sKI5D`USOK?#Q2-s}Bwq()k2;`o5=oT?_ zG*mJ)G?ccKw%50}uID6?dCYPjU3p5 z^77WM!os3@cA_{U+EV^7@vVx{3PA4&1Q9h@PMKsJOy!djk)0kL0VC@2@+x}yv_b4k z3GJeydJ-~s*I(!;clp1`%l5YiVnRa1frHYe=UR7IANenva4HqoAo9Mr7~lf5ctXAb z7il|u;h+3bQ&nAG9mr~k1|p(>r5R|10Xxz`csQJoce{XqK$}n7s=&%uxMbWBE7^Wl z#et=k63Qfc&6F@u&0Opl6y*rZYq};iU}gf^I?qmIa2QQwp7C2L{Y7xqzH7)R+k%$j zbv;3WFT#5higDs0AqQ-a7`-limQ$!Zf|NMsd*)j*ODgek#JR;8OPu4%n`kjmKv{xH2R zU&3VM`3N|89i6Q0s0?GGLxl7U$}$9kM)9RVT7-c_wt$e3C3+FRSZe9E5CpdQOUCi?lY zq8KxW3Yv*m?oALGCW+#t&XhKiQQxWrf%mhA+%G4Cq~ee$eP>k@oGrCM`RzvaMpCBS zkWT2n*S=T%*S}SOVumY9OArUa#CzvE*n~bVbwOj^C>YKC7X23O#^V+k!=|Lu_VpxM zM^9ZKF+o`^MEu(`ew)L@Zq|1ht|xTo%fK|v-6?IHzHk0sdII)cGM19``${!5r3vA0 zGtYfDR2QUD5b0RVKZatC-Z#hA->>FSPd9g*pV6MLv+w?Q|KNlAy?ItQS(JgO+~nru zRQv0fAUnJJ`&)=We!$m*Nnn(6xp&}k=KJ=DpC90V;If4Q^dRHGlca1d*8}MuXAp_{ zYp>fef?XZYVKMC!u*{25^*VBpp{dn``(H>+n4Vje0%GekGsm1$N4@oUw=K#M!K3+jRZTF#E_dpv4-y1SWrFx}JBOn1+8cbjII&c}3j*YDo@`}>?Z z`^z~TZufOvuQYi%?R&Vg{ypk_3NhJ$fHoi|XAsa?V4k%}kF-aC*wmkm1;dc4S5Xj; zpVte>SUI5EB(K}LrrRU0Tbq9_+$tR5F$}>wNW5XSdgbgas23D~!2+;Rz+I0W3AZcB zmbs#%PvQ0A{l(&om!(_48>YOK2X39H%s|Ku6KHb^d{uDgFZ}U$nFJ@C&i((+9cX~? zykF^l(Lc5~m3j9pHUqbF#?I4HOc8XGO*+{Ea z*6<3FyGPm-D>oiQzG5U9x%iZi`8%N8ErwDi5-G#gt9L*RrQ5U-!Do;B4hJJ{OAM6W z0U^Kt1_C{7@>c+euz8~Hmj=BJ5iE(F!e+=t0|CLjT zR>>YeftI_rwug4AdkUp;<)^sGxDONo7dG1mTMNoLaiSEN#QFw?i-Lb2`xe|=r=6@?p1$>pFldq<5lu82mRLaItd7bo?*uc#<5I82Vv z=V!GXt-(XMM{h$Ot~k0Egyz?kLG~vz2n3hOrKUM@QAu-P3V@9MWTA{2Sz?Y^=Ot@-}sT{tyMMBHi9syD=C6;HP=SW}u0Cc4-NCE5<~@ ztXK)e?I+{nJPuvQhj8!(TBMl(>BnYtbaR@T!NIUV|Bs9{zteh$`^j>sLh980zX@a9 z>9VyOvqG_-tJLA{U;>8nxdLPBd$8d2fIZwed91>^8X78ETX*_GuYkUYXV7QhtKW=< zDiBN>2nc_1yk8ByO072Pay}Ytw9{rcY5~8lwvK7D8MZs$?4BGE9s@THyMfTg+@ItM zSpeJe8qwn0)wi^ytGaOAn*_+-ms?;FTJ3PYSx%192Iig1!DTWKNn+6MyoC^k@gYOR zb(dwi)BA*}u35Cj?_sof{WT|JnQ18qov_KgUo*1FB|1=5O>Jg%HTuI7a6YcRLUt!s z_?=ce{C9+wJHK4a`~tomb7sKbTiMY;>KG5F=3ardzMQdqN}g4y!0)ssC9To@I<))4rfA5VoU>{ZLvH;X;jV*sd&D38eO%&MBI5f*y5GZ5O+qa9*OXwy-_vIL+I zptVQHe7M*>W{yFp|IOdxsJ+QtVb|B=)~G#zm4MrDq?96dvF+_hxUzFcqu5XoS&!LEmZm5Q+apzUCqaoFR&YTeA#>s zb?v{T!3_p(Nnq|7bofGVzCeOk0$%qh0J!7ovN#I%#V^9bQreOSn^P&6C`mt8lcO~w z-tF#;iZr-Oj}8}J9HxMqP#KF+S+S^h{0kgo`iIa@DuZ5M74=3k)N^7VLo?w$Uzx%& zDT~iwM^%A;(?z7uG@};WM=P0E%nD<6<(}>)vvYWwX(5)8Y6gAs!&nIBf!`$*p(RBa}G`pfg~+^ zr;}rwqr=nvgClZ*KLNX!=?Z({lh6Rdu$hi{JaJoNUUivb zuG|rZ|4yFRz>B8Qg?~yR6;j~(dA6{BdvmwG8e77>C5VdhT>@bFAg2#7i`_#zw@Lg@ z?@>3PHoUL=gZ<%N;b_3u7gCQ`5s|zRk!Wl%b+q#H2BLY}KBK%=!5eZ23N&qLY0wk# zp9u$uEmW|+;29?HG6YkRLQ@>Wzq4bqc*lprg#&FghL)~thm;$PLeT;x3g+#K^T0*B zT7dr>%%oGqrL*J?Xm~sv{Y@<_u15fNI#@X?+K6MBV=LmC}DbmhK&O2f!C8f zK5lF72n!8>i?yK;?k{&x;{fFO@p=i6fpmGFax-+mI{bNb^poW9Gx4Ea&|sKe!=G6dM39Zt z*bPvA;TE_!JJ;6H)dke0iC3$JVIfu&TGlOj8E~1W`O^R$-#&qy->1JsVlLTkh!Ic`@D-wQ(rk7L;$U&D$#zB<4o_bJp zJPKSV0w?k?>xV(-Eq0iPZ5ap#Ck!4Q-eScY`)kx2Oh;ki7T_vA%b2O+;6$R9l#mEH zVY8alO@Env-8~(=+`T-UJQVk)_}wJMVq~nlFgGhW%Ly(y8V4^-fu2Ch3d%BmG74_4 zhPn>Y9UVM+#27;bL9&ZT2#Y);9JsKa1^eYX?`6D-;7ij~AdHpZ)Xs z%{Nm}rTPIYdIQ%wNy7?RHvqGuO^A_~2P+kY1N8seINAa@SGUW<%tYow?VBGk7XPUn zJKY~H=K#00afdIS&zyEmWt88%ezTL``6lb)YKybO{V`Zw-P~5!mIGI!y3M%={=Alw zYG_i-7SUAvNS$Y zB@<*Hh@1v%MM)ep|6eStL6`Tzd?iU+tv)bV&XJV-DOrtMUA%0-Nc#1A0A=HSGFqwM zEP4;QjUic>me%&LmbUboT3ey9Ov_?q{q;201A0p?uj%kQ!gn8a3U>4h)T01i&>fwA zN4FDWfPWll$-KKke}_@{qTg)!MxyEM?eo*_C*YCF^c*k`#^t6^8Nhvyyf8c81Hm5j zKce>q2{${gwRs)UlXm%U4TeS$2nlg+7e%6?GoY15BolGq2*^xK^!N8`Z_Z4RG~f8Y z&DkwnN4C1`EGGlQp?7+PV)Yf$zha{lSB~mOhh1D^%M_U^x-AE^50f)T&hhgAFiv5D zz8WegfZA4)ENl)>keqLa;ZI!xuqCi`0ORo2R89L^Z#V@AIHOlqXH@i3kiuZTeR_%g zaH#I#J3u`xYi>>>;juM;Blc8J@eKJL5Xi@F*fkp&l$xAgA}-M1GEEqwK7l}@N9jJE z7uou)?d8Rf-^u8$8~|dC|CtPb0adkoz=Vp}d~n^fv->PBFMlpNUe_3_%O=W^J}oN; zT)iI3cfb+rxY}~NiP((UlWFsJ@WyVT+SEWB{0cUhGX>Baz-xg-^9i*Cr9{g|M#e$O zL4yL+2) z`O}lf%0xG)YXtWLK2fuE?mv#_JO_#J?EO{LKQwOf)5_cLrxoML;%AkA!|S{~x+k^1 z?CtTDkF96Jvee($AFitus|OJq@-DU$H!^~e5pohIz@~QKWHsxJF_rjz)GV}WWP47= z5V}kBqSFgyACB`gxi*=$Cz)IUDkTzFmbLOLq&Np3g+d5M2D+97)u;G~gv9RXXk|rz zNs%5G#WdzUmU4^6A${LgWD6y_AUS&sgS;Cb7UHkJ&M_gowK~rZJ(OC1)5Q_6IcMSJ zr!}a9V&$D_|3*p~ltQR!G9&ijHRNoFh{ai`$bNNNNyl~u%bO8uqS1;{V%e|<=ToLg z1KJrNTLv)E+}+ly*&ldi+%rFmVV|=Z!jg?*%^6kYqRj;!j2>i zldDO<`Kk#fcu)^-b) z#hRzUC@0Im!(^HV|NiSQEXv4(X=r%JPE`1A+4I1^Wy6>q#)XKJ)0U$)P}fyg=GTYd zbfx2GqG)R=Ccm26VAhk?rR1gLlb!5-G6mgyl^PaRy`&1;3P<~LyCa+9eaF+jNk1nA z4`p?0Yd;w~2UkC7L$|u0A}NX?Thl7E7GW@pOms^fL!uEgzqyCpp;qd8<4gpb9L$^7 z{`3qCZnlKGTqlh?pMU3-yFFGgM?PL!&>pe!C0Ra$tBpL1cZEgBODQ0$?zfbFH9j@%9xZXsoZMKOCNtProVJM}x-FAmIgd!A;Azgj(j9#p z5jMujj1l!Fvx!hz4+JMU)L z`D}Y#H948Ho|7LG#N7{U$XS9Ow=Ce>+=5p<=A78{`pZ!ePS^ zah-;d5)z#H@j-OBWZO#slVVvx7{b;GXr7JgebQWK_OqXIIwxuDzhq{F!?nptS(UFjfkP0~B2jH;r)qxJzl5LH{VP zMT^xrKabxKt#WsBC)ZBsW^E0)SKKr$<>Y2Lko13VXVZL)H?P&P)U-60lmvV&+SVS% z#$tcnt-zi@=86s269935l@K6h3k&P>YiobD{cLE!^@E3@|0<2$E^$vt?1>%oF==N9 zN!HE|b(N5CpJIjwO;liNVPR!@dU|$I2Y_ic4VM7sFH+!Ij#LQici3Z52XeU5A4)Qw zhEEwfS1_*iisMBX)X~K(x?u<_E5dE~FF(ELq%zgBIJhTZW z4?q-?`#n>Xec_a5i2VtxuwR<}^ad36;azl@NNn=5SJo03| ziWUuMPPLDaz3a7vmV`<}&WbI^O~&3K z{-jbYW((R2bl*$wnaB_z??t^WaL^>tDL_(So{VnnUfwcT+#>J0Y&4Z8<_GT9ysd$u zddJh@eD!ZvOzgid@%3)G9B;%-V~3%!p(?(S7op#|d!VD=UeLu%Tmew@`ugeXt0>D4 z6I(ghBLf)5AZV6`YXy9mJl;;&7h=E87e5`_%fR9vBHoWaK7exj@rrQ&2=Dr?AT$X2 zd|f=DwrDY&j)gH419ZCJ7{QH+@Z_fDoyufX+Gf_h3;(X}7vugk7wIX*{!u?8!XvPZ zEekDw{%rSkQ$;8lco8TStF;eD+@Z z=oG2(K)Hd~LV}VAQc$a%HtpL|VWA~BsLv~S#}*tFvJyK(xiTV|kHttf6cpqt#W$aB zL2SYY>YGI3W{_8~J36eJ5$??W=MwR`Cuo$yo z&(uWSzanA4lM;W47O~r(0+GweC+eyWOY~fW8IUbLw8zVD%`1~rOSQ#^`I!%M%5QWmsp++OUT4;K6Oj~~Y$*AK-VDSoGzaAZZN z@@w(B^L;^cLo+)5;NiA^d9w+0O0pXr3VSrU0-Y@C6tEITG49y2ZDD9YsnvzdM*D0ApcbGF5dv6IcLlA8#Nzqb=#%ggkmR3VQ z+|NHY0b(04N8+Rf{(MR)z7>6G=^j2lfMZuw7~(UZ*&Grc5_c^I92^3r!q!!50zPCd zTwito3MAKXwfsFOq;L*TJ%yEQt9_fjTAe4pARoytc^c--ku+fm71#;x<=6iIWi8c&{~1(# z1ewG2SrxL1iLfIqms^pAAz2hVEZp_o(9GwMAOsQtS;b`vz-MRl^0Vx(pw8|AK zj2bx=J-5EvJ`14VJxlrAdy`pY`$tX=j%8Ip0oU8KzlvN#QAGnq76KJ9t}+%?q8^DCX|3CSj`Jh z5ll22pIHgs9ma;OeGfsAmckLKy8NpA!bD!h79|AQrE!FjyR~@o;2|RO)o|e|~>>?5c!sBBYB4VxI+QI^Az|V3f^IB{g6S#iy zC7bGMCxD%Cu(h@S!OpHQC@-(Fz=6fj!*+tri4&YuG|uuK-AcBA9-kZu4ve2zY)NPj zB$GP0IVia)X-Z2|C-m$Jk&G{+4Pv%LM5yNJb12zwn6bAOR22N|_}PYiQ||mpVJlKr zAbJIbo+@G0lF}qPA~^Ud;VS}kH0{{vksXO%=8BMT+oR7@I6~NDPQsFlyN&0M08roJ zl!mGiIeNItf{RNEYYo|$JLwK~YseH#)w7b|Aefk=3BZGOBTU$0>hYy$uu*9?6=eyf zBplI+M@Y%Il2@|`DThW)3>rpNV)O0s1)|fIQvBkDRu`9a^mGk$jRh4;M@JKZEZO9d zZ&Gq{gw>HXYE5S0Q`lI8ar65Tn3N+R_7Q!=XAu}qf}jY)8v2=4^APIl`ampMslU7< z8MlwOy-lxIoz`r(+3iz4yY|0(Kt3M&k_yt)MDM~B`za0JERybrZ9uhq-?86W1cHRh z)oVm9M}60oU;gQgnPFN6*JmD3{*9>!fQ)+ETVmrwe=+&*E_(U~#n$t7`asS$xRDC$ z-wm8a3|J-)v3;fAd&sbp7`B)(!QB<-@AgOF`aDtt^I-BoWZ)OWk|MrKC=Fk!0mXX> zOEt!?P-%+v%)z9vS;@ATq8BFgl(hswYkDDiA>;_i z-`}V#(jTy<3QX~9!2;P+Qhtw(DTb|x7Xavtbj7A7zPsx$H@9DIZrtu}-EVH)Z*RYR zUY|p>%>PkMg5Wir(vjlxczx?jF%J)|jc|4nTGFlEboKN|>9c=!1pOH6+5lx4U|hXx z1_jLr6+(?mx_@|dA_`yXx&OHIXN4I(5Woyg4J+F8{LkQ~Yg`0Z>PUwSS-827Dojyne_7z_SDhQ{^6-f3M3)@8?i!=A4U?Wk_xI5RS%~q#z5eef!W%44s8aF!PxTlGv+?)Mf{R zUi_3c{dFu4A2KB^3-6hQ1V)5c0e|Ow$2Xm%29Q4H=C01RUe^=%fA-h`!^P$ONPX#V zqfbf`Y^;?DkEQu_9p135IQ(Xx) zunnP?KA`w#6767yVY##Y_&zEyI5a*Hx|kXPF-a!G7F3h9YUJ%?>FV~`_Hp8w(EW5M zb+A;sM7t^FPTi-;JiLF?qM)Lwqok~@wzH|RytclywY|Buxw^QyyRo`kdg{;2pS9W0 zwU^t+rl#fjl#JbypJ9Wek8R)dG%Dn@G+`|@H7%1hi<6Tr@sf+#n3xrS!gVdx(1N<3 zhnI&LqZxph8>kuGJuGQzn~xogT~ArBDyiCC#<%a2?pGahc1L&L)!Mbfw@#hcYHISR zSxIMnUs?Uxrp(r%+-||fuG;R*{#Z)7WqD<0V_|+_W%*AKmd5<|Ukd$H4`U}w1_(YU z`aUZwJ}W>?&Vl_$pbRZx)WXZv&|KTqCqVxUqA58#4+QW%Ib1c!L-CP1(1`1`V-%l6$6u=RZ-`*8L8UCs^vf)311m+-R ziA<(VvP=eq+>N|9ixiF(PBs;GHb2TME6aXX$)=#~V?cfKXC6&+(UcTv8oFK(=qF}+2@y&-H60cj&dLe!l$!K$^W<*@ zX_);TCbIIhLFfk@$rTM6SuE`A%77J{N*5+kKLXz|~f&2P?qT6oW47!$F?0}E8jlPmP3 z9x@$!ZZ$yh#xsvG%Wp;6(0;BtHOg7~OW7HohK3`&v?(QGq-qp zqAaCNop_0D2u;3}Z3jzwPcl~AP#>WcqYa^=Ob8T#^%_hiw7HoTH9fc_(Dq{=mi_wB zHb!FdfEnGi4G%6#M#%^o$t4mO6F5kOm*^uU=78-#SE@}B+&ekg1QV5Jh%TPYy6R%D zra+}d4HkOnG%&p0)06Ca0hm0!g2TIA;XWNr)f_3HWd_On7mv#5i>Y2iAVN@|@V8+j z?G0zO2w5Y@7Fo`4fr=fEWC_yvz_8h8NI$mzCEydW%Op{-D@QFKW82ljXV@z(CD`2# zXjCRMftj73<>Ti`PNj1mRqR)B3CbcB-et&`*s`!SddtBzY7LBGsBxd02qrMW3?B!w zQWbzUhot02Jl-2kUN=qNM!Y%Ak#80qJlJTNVUdS~^eR90QO-B;E>~~ncf8ih%Buev zP9UCe(ObG=dw~BtP>Rw)@NM<&gHL2gz(4nij0v;lmYJgNCG@$E`>(Tgz#&cp<7-cN z?~e^RLh12v#X>P2cZ7e?!d1^^{tQ=uit7o3BdYVxYe=Kj-pbnA$IHvs-QCL6-_hLf zL-t6|j0pmv)Bv2so#Rmzlu|9T5GuT)h$^GA_R<}*pHMJ zq#|HoWVFr{pjcPf-rm{XURYjVSYO|X`tPcr*+1QfZsR#S1++EamHfcNYla`z3>VMN zP7d5R9gFX7&6Ew#D$FSiEi9sfs4+SphhQGP+aFgc(Dx6*rnC)y@$MfSOU5}tPvTCM zE11%sf41F#P*I-rk5rvb-tO)WW*ib%kix?R6+Q$DGA6yttckY4L;RiEy@o`n=nIF? zdcvL5D?wXKUE|Tu5dl4ojXDG!6ot%-$~aFj@&krZx7O`97Y=7_p!mZff{$Y!6c7xG z46u%umq5kjY1L#&$)E{GO-F+O;EA=ex3{#sIX_nsMAx&##-1El&(^RN-|dMM$|7w4pcF^o&rkG8=^5z(x0g5U zV(nLeQ|~(9zWwcO<7I8*?PcTb{`Ex$U|@lhtl6biC0+^E6)kB@)w_7*a7auoqX+$eWg18G@W%oEVA;!x9M&?^z|wgfz61h&E;kMhQzu! zTcMM{JJY9Jp}Zg{!cw=rEIWB(XC$<2skk+}(?KM8bZm5mWW`(0C*x;kIriLzipW4d z6k`8JS}dl@*abdK9yk6kxhEp7wP(-ZI~8Lr_%KK8**w6WEuHQ4 zoz0n(Z9#p7IGZ1zo%hTJVhrGIV|KIlq-eQNu};U>@|b)6OqGRbcbj?gDXL}0CajD* zo42r|2qJl$o9$=Ic6@>f>k|$PCX*kk%QBdp+)Wr+Vw=$=!CZ28Nn`p(7N}AS<&M-- zj@dK7hGqpf$Dt_o2Nxxf2yhE4ZVR|iPFlJGt%WqhmQ>~i%S|T&;Phgy_6e-~uZYEv z`ml0Py4v5)lf@K=eE1la76<)Ub3v2|s@qX#`ym9t>kmswRsFpXfVr*ay;?z{7gw+@iX(@m! z5y(tY7dSY`dS}7HV8>kHx+GAFz7$($&tSe}8-y#q#n=!=AEASTjWIBV7lcHtEhB+0 zXtV0hveLxzjwor1F*3%EA&Xa?mK;Zq5vHx#(8YdDVzSP)$$UkAGD@Onhi+NbehU|u zSS7@4tvtJ)g|b{QgnAJhWvq#GhmraknH~IBg+TX&Dh<((E5F2SCWQ-o7CTQ=#a`S- z6U2b*PHdyLX>V&IIZwKanHG_LBaW6XG9!eIhsB8$Jpk>DpYxXnO4~5-yRGi+`sVB9 z#&c^DBY!ncppBtdxd5g@0iJl-KsNq!3VV6tm_;6xLD8 z5P!d7gVnInlx)Mwb)({v27iEKfa#AgAkqG)6$~2R4r%}$m&hUwXfXa-a$`XZ9_}B- z+5OrxsNn*kl%u;3Wy6B?Ly3pTcYZuI-s;+Px+8~r3-kB4yjc2G8kd8 zBkSN8bB6gdBZi_dnG+jDsYFgyS=bz-loDVh`+xkMmgT|igQ<%|GVz1%;HajA3V|oH zfq%U?k?G0y%diX6WqeOWdxvS2f@eKx+=rQ#t|RM8&CZZjo#Ad z(lR{2NKMMdI77GgKP-R}x*`%cNU=XUq(2!C&(JVCmEG|81NR@sMI+1Omq4{cys!2we?`fzm{(fz>h zA^HZWCBgwyAR&?0&EZazvj4+$T)^es+eyIF@3+&l*Et{%zpc4BYP2LDY6nsl!PW@^ zd6QJNlP~z!TIVRo{|+WnGg2NHAK1p$bBF&w{;dj$gGkY32^Tq$PBjsejn--~n5m~u zl9*#rgKwqG;Ipf>tF52cXWQM0)y(PEgxc2Wi(eOIzeWIw@lMRIo0FUD{?3MM$)|jk z7M`36c_71|ziF=7jmQ0#=VpJS`|Ql=l%qe_|e+nT2iq7I;?zySUKd-Tw!sh76 z?&K<@X}zmy9eNgi`SEcDp7F%-)UnK=OipX4fYWSQ`J~dZasy*EK9$Ojfw7W^3CaxkIH z8y?xNLpS(rhI zZAQX_2*EVi#jQq`Cpdds^_>@S?KBJsOal$_L5aaZ`zE24bAO^`-+@^r9vh-9J(D(A zkgRNk2A_+gMv)!}TWYSU6A|Qo`E0*^Fh&q(B;s}d_o9DKx#9f!BB~TfCCX0AUR_>8 zgM+mkvG7xJaMRSJ#ioZ10>K&NS!%0bqw%j#y@Y{3AR?liMpdi?Ltq54rZTq#n)(7i z1<|N0^dii&84%p)jy+Xm&lgYaU&qBiusvbjzkKYue{ny%I|URn955@kPh+jKKBRW+ z7_Wt=i(;+14*?IRjTrw>9RD$-XNs!tZ(Bi9T%Wu43Z7i}LQ$_iAowTz+u4k9pnch( zIr?PgYMoeo)5Mtr^`z19sL=URtF4tn zA>w(Sl>0AJ;qhM*!Q}&ihv>`opWIg<_~9tv%6Cb z;i(po(iup66OtB~Z_3J`hOdZnVUQ+-WoVT4GW^CD52HiZH=4Ayotj7b^;jT{kd0C> z_8#^0k!@9>v?-*c%OvO95N0EI{z;Lh64DJxnVn#OXVoSn_=>Oc$b15yhOQroS%t4E>}fB`wX>HPu};T@{o7zSYjo!A{=+2+7m;$p#}4pJX&Q&NuNa z@bGK$^J(zTYR@jv+A%AXkIIiu33vx}3o-*Z4sUl8N4HRSeYg3NR!33M&kmH#BcP6G z&Qh}mttOHR+S50P#DdU1a7sdsLl2*|QenPPtJ#g;{igZmT@wbnI~1%W8SKl6Unk%Y zzj0t~RC2>>+#ytIppEW)KWmK~TS#h!f*`qA&mNPS0w5!UIQb?K>138;>>q{GqqA$X zvijEW^duT_l&$BPgya|+A3VM$xyyLDlZdH#Vy{y|u_q}*(DMv?`^kc)15=IIoEXY} zprkCL>uE=_HNH7+D5h}?D~t|gL_|OjYfx5KrCOWOlcG@{$gG=_Nh?PGfrDt+<~?g* za$@C2e3R9X(WN`_UNR$Uj4(lompL9~yZd6KhWoJ@E7hmiCOk6zO1J2Ag$OP(gZ8v{ ze4q+xvcl3T8?;HCD_Oe#@kCJ%ziY(hMgGAV%B>w65QFwR)SeLW|ed~UC2q@_rT+B1z91~rrBjRX(6egjZ z{68a=5kclXLqUH^VATxbqPS_vNPp!TNRYA09`gOD@5{p;4h_@baUvTMjwfXaYDQCL z8fcL9pk}7?E^<1E!ZqR_SA-b@Vhug;wNpPbtc!TRUcV-V$K{Io0}JWVw05D>$HBp& z!S(1^<(S{&6ID(XmUZc%sadfu+PCl^A|zRo0b0rsZCDBD{dbHO%1A#vB_xd$u0{X; ziMHSnP>FNKRHWS-{UYU3x2g^+#}-^z&~cU1&xpt&2Is|W6KJN?we6Go2zyC$P9hD( zp4_eap*xWx=*$UD)a&l-<|E8=ocG_iBh-;2R=SahfCt|Ih9I%J4O6&7?4YZggaDVq zztui}TL#wWuATzYb`2YSJO91^A_9%fB{JT;zeZp%62xDS(Tn8T=yET>7!Z&(sxc0D zq<}={8$J=Lvt{b*8TejqAEX+u`&~T$8b3Y;An6eteCUur7L0taeq^AFNn zy=4a6Zak;H&Ar_K0<=GW)E5in2Nd-6mjUsHLC2S3K(0|=SjeNQhaH2xRW5Ox@~>W+1F6<$`2QpTe9+O2WXIw zTeJt5Yi^8^D$1Cv?SA}ls`zYQ@wsQv*HuZ~Rf&5_FH)c+g^2x#f57i||2Zs+lc7ea%B0c56wZ2+zV0O5n9o|Xcb zeZ#U?{TOCIqByFsQ7Wlgiww~wsEEmE&R}j;7pQw*7F!kkQetk?Vs2AY*j-)RUS12! zHkZ1fHJC(~Z_~qpyVCTG-)JcuI5D1Y!iXQkfP=@2$E%5_S(}J0qVK#gD%h&X<`*xh zXsPbWZuzxRd;ki^0{>Gq%Y{DN9A|t};T;YR0cZfA%Rl{jzaWuAU$yO`d}8%!(@_v7 z_Y;CSJsBQWRPoSAQo1z6td9sST*9*jkgv_a0akqSD5=6AlOCRE8p?sddBQg4HTn$U z%`&qrpZzowP?chl<>(zMG}x6^s#G9GbnN2C zh7kYFpRx@5J@D6o^M2eP+n{F#ir%t~uudqLDG5=x<3Dd>;qZJ*=V^&BcxT&p9;p)y z8pandkQs-fV6cf8fj>shcgH~*?x1K1?z9XX{B)RxOi2$VL0%D=7S&6cO<=Aa7?vIZ zMoLRxq%r%kL#ZS$9!6Vj+hWHo{S+RK%Dh-l;dA{w(m90D<$Za6vv_pSdXSu491}A) zQF(JSaT600b8=Go^73N9ceMXoMIT3M=3RkmO3suE^aEvNfvgo_-G1znBrNFNWRZgb z0v;AY9v#PM(n{UfNM)Z>0vqUE8Z;){<1*If?3Iqj;W82doe(1J9>oR+OxIGb7Th_^ zbt$h&Km*`erU+^%CTA~EZj6M};XBSOEKPZo|NlGWs!i-)?EX;q&%!Mn>UAN`pY#6@ z8E%dayz32t6PVT#SI4|&AnsC3_#DBA>UICg)qd~%+{D7-`eKX0XZ3kNVP%wHcuhUbsu5ap)`ba-Mx+M8 zy$v*8(p4>hI|L||qrseGD0nR({M7gb_hB3ylo2RILay#Om5E`T+*mmG zSSW_(nq#!iP{eEnW>?g_cIwy12eL^T}S=7?jLRWp@_@x*)uC&JL zs0gKJ^)b%oj1{*`dLW3i@N1ZkBuylM-vesbOev6Yg5q`%FBUgvf+)BVMvC?fe|&&# zGjxCw)@1e6!P44g>1DQe_jUL1c6sJ;=9ridY*ms%&p^O1L@>yJ&p@fjsx-m!d%SwG z+8#(gceFooIw3oyC{a&07}UbAvgadws_X@E;G%t$*t8K)!)AMroE$hrO$TGq@A*6S zWB;_Q05Bj?BR=uz7(byE4PLT&NzIFAaZ@l8hwvAoDXC2E7m&cwRTV(1;^ZwI14j zuyOr_E%+il51sHW@mGEWT2RP$K0wsGLqUPXhGKz6%95m8YBIpV0{V&dM;KW;(7Kn_ z%@h@S6_(3@a)v%yDmXT2W|H}&VMdzkbM!*!zN#j{Hqkg7HV>)e6bfl?0@UFGE!KBO zaXEGyMB+%CIvX@=sOJR)oVPZ&DGIP2D-V3$K(=1IAt>09QWA=Q0~e~}qdpS$$n$^_ zU*?44nZ5Gw-yP}16uIQNnYsNAe0w^ZvWx%|IX23ixPxbj7wKush$}&QY{2FQu4%8Y zuWhYutPeeur!r8q$x=aAhC&*aL{3K39+PRK8L5xo4w;!iNtHxfRS)IdkG>98|7+C? zFL0w047+~NAK9Ot{Ih5vuCG{1i2TP+qgEEj$3W~n*|BkHLE~pbnVz$=IBK9J?^*T! zK=?og;(;5qV3+Q3b8Ssl`zQr5EbdzWr!(FVg=rb^pTZJu%x)h)8v(#+Pp|~<)1zFa;RE`pgCj5r0%LKwZO@Uhnepj=3Fuh2q~(3UGeZ{EKnP$j zYV*O&$H& z^Z{mqTrMq_1t$)d-B*Z2Egz+5G5@OEw?+Xkn7dCwB#}riutC^%3NXLhRFvhx+847k zh;7j4BWP@&X~BUJwc9{6>hkhDmx#z0Amp{}QAjwIjKuHdezTu{YYWiC>-b5kn_<|| zBcQ-oK-*E%=XMn$wJ=;;gkYUh<3>t{g$izZYh@*Sp;o%+2!;iDZ5MIRGf?P4{#FG)tgt@xDOV4+I)KsxafWsd)a$?5PK1O{quG$_M-fDAof!Fww?Q&^ac^T z$M^?gIx#UZ1&x0DSzBBCTkP%mkFwa?VR<-JlG44zsDrUT+~{5wzGq?UxHs0g>cZ|6wICfNzoUy>B}cgGC_T>dj>fc1S+PRA??C`7DlzR7-H#ykZ?O&WP!3;l zr~W<>k`{Tm{XqT1_th6#N{u)7Cd>K~t`7%6yMLY(%^(+*mZlm&x?{(<2gKAAwJw4k<#VK^5(&!&V(@^PdM zqfn>_-T0aW{i|LNHsAF-7Sj~Oao&B0OJ4z_JdpR-!UibnTZp&rus2M$#1G$Z_)NB= z4hZbsLWNU`Mv?^AMXxreApaB#(JpDYaNIFa{p6VX*O*n^O^U1bxJZs|t&d&e<%K!Ubd z(I7?+9cQ-Gm8F&RoLv8BU_mNXrY|W4WGLnrI@Z=hK-`7nTE|T=hS+m&c);uC2}ZR+ z+sx9mu2D)u-Td;*=)isEUUhCvY@DNmlWs~3KJ*t2G@B_B@2@=qQ0zfcKsGT7PBoMq zxwJgJ#lFBnI!3Y-JTj*jCDkGU*eBRQ^vM*lAee{O0f5e<)1hd{eu} zkO(&{xBOQCOXfW_N2lLomkHNrqq~6LuW@I-kK}I`(vXj?4Xi{Wb7u#Y$7SCC_m=aa z4rHh8MA*z=9F{&}*RfAJvzqqak)dW;(!}m6`vIy6L}T0V6!A$=e#{f; zd(43{Oxml%cPo*_?r!*`6))16+ra>nMiFSO5|NX+XGYDmjfTk;W`v6SG3`AJ;|4t8 z)_8j$T1S7PZ1W)P4hIg00=u`M!iX7$Fl1fCR9ljqY>un;<4D0Aw=8{}TA~}qN37ro zX|UTD5lPV(bZQk`|Fdfj&ZA)6$#yC(P5~e&xVS#2)5dv%S@4&JluKJ9wtEgsGr9wG z@+ZU4$yWNQL(&-$AokyYY8n!&XU{SA<;jBh<3E9eL3QTWqp?X)wixE*x0R5cdx98HF@Sws5gXc7Bi1X&j}Q z5{f&42!mhb>=x+5!oc7xRP0xz9|K|+Gl`D?3xY%t)Tz&6Fh82tBVCf^oMv)mWhG#t zgvNzeJa?N~ZaZNd5|5IaNfq{pYuUfPR^P^g(+O_(TEIp3jPKn>O(+m>(&hl;iQfIA z@W`-wEcb#Uc`w5}&GtP|i=A@2l_SL(~WXBfQb4ZeQN3$Hu|oaU>?d zl^rty?KHSMw2nmCL6l7a`_W1U_zaj_y$ro&o#hXUjX-AVC{)u^-g>-qEyRprylfD>g9|ml;HYBe1m|tQLOml}8H`LZ3$T zTP!eLry_);1S>kMaA&`*f{Q#Vyx)}DWO;O5;bemCw=$a|jg-ERewjV-JEG`A1RNi! zFr?wn+bcz4Y=8f(g)YP5Bq88X!1{#PAWA~puQWF|=YDX#Jxng=ui9wwJ~?c4wH6Xy zx%l=s?A<)qJ1nV?cc7f<98obF7zQm^hEFckTVMslPhJxE!+R!lAbfeKzG`UEHT=UQ z$n(F`vI{xe>RTD@FveAy;^o>wd^D2z^b`fDi_#v&)7gQsOf@96>U0MZ!LWlfU9*6n zgs15htTLi+aqW9lu# zs_de6?L~KYN=Y|JcXy|BcXzYsMR$WBCDPs9UD8NMC@n4Ncl*41zuyLb`2+M&=A2`U zYn-P&7+Io_DNPw(GNbIO60&f0wf@N8jgu**xRLvD7QL{?|LWw!?%=Wm zL9L-Mz~9MtDQ-LzwnUN z#RJg_!`6u4SZiBr8-8Xcx+Vj|U~rU%3i;&w1IRl2J;wbi5S+o_N6G0mU87N z@i-hZN9IT<$re$C6_U#8w-%%%y1Sw`N<5gGSjrh2Gm=73Q`Cot;gVH%ykN5tDKM?y zwTBj`dRgOBvBSj2d>E%LyU7j6wSX-0OB;t(gW7NjIMWV~s1%$uhUb%Q0q3 zmg5AJJWc^;`VO`L%2+q}nhbc$?u}H6u9GN14eovZLW+FAts5bmAy%kA4YjnkzD-6% z@~JSlFfUI#5eF%H&ZOP-b@VRea_2d(N_Un{albee;z5RlFsWccFo&_z>Gc|Nclq}j zpb{n0DePbQGV8eioGpk>dS}&`dCo`6qcy+kCO4-*cAPyil5*lCf5x$sgcoy>JvjGY zNm`3IE`4NVm*2J^P%tnO5r;j`PYC*m>rJ!&epnrujjf)JmIrldc-xbL5D_*UDC*Gp z^#!{=@vm*{d|dySV^kQ##rtM|a@WMG`YutZ`{Cq`VD8T4K=koN7kJwp?wk1e-d352 zJe@6#`3XIqEzx}ndAhxsD-3$8vhfjFZNE8-6IAUB{C%=_dYls3GE(qLHr2WtwZH51 z9Y4%qxZD>T8Ey!G-PSe)FLFSdc6~o$lLyg6bf$`-h`IB_Vi@xmBSC3|9*W^35y9uc zOMnQevaKm_(eXCCU6n-#G(?cK>l zlt(gbndIP!!BR9D{x2_}AXJWT$t^4dT8A(N-cM&<4V;cjWJ1o@a>94q2H#P6q9V`j+3tJw7wEKX~oUNjVIj&tS#X(d~6gP5WZx5KA;Su2HP(Kk76P}!$ z*?*~HV}K0mueQgOkRO}*QzyrhS5h5BPUfh6*7XjLjZ zgvPtG$n}ToMWQ6BCDETs=ua{g`X|QCHn|goMcC21D259}3nwXHaWFQ>V;j!0CSu9_ zOVcIxJj9^1Mop5b-*8vU&oz$FK57%$VwLJY`roeHQ61fCHaEke|D3>2bWp8)-=n%j zl+3Q3p=Q!4;RJhkXCawV93{N*b`70%?RB2V_fUhL{_hJXgs$K&h2LR0Ozc=%DV4CCdC8)|PWD|D>zGq1ZhTq1lYU-+axt4F~x_GcVAAbrv=G^XhDA zJlLOzAhdhW4SlGBs?OkIlx9ehkGu)Y?lc9vx(p094S;2s=jb){HnOvUn=V8fwyY-8 zjwT@E-2xNrn>}mRwWB_wU5bKlqnEb-1<_do)ft*j#LyT(>q=D^|W8%>*2XDj{5(pO5;GR6a?*~mLzB6nOBDF zk*GMj+~KZVl-=>ZS%m>P6Vh}(1X=``sHPKNU;t=ns^RZRZ^x-wEBEywC41hMww}J8 zhOUmzp5DfmPGF4O$0OR;$=<~&+9ZUa#5SwKG7CymTuMV(N{iDBi>$01sjQ5&1E>Gv zl25bF%+^e^)Ur6AIi8VSSi2Jm#gBWa8RC4xeZl{<#*WH^#n*Yb0~{&u)*u5x8~Uj4OW8waT$dkfm2U*;aK*p6u~nLO^P1nB^GY&zoNl5up3I zsbedSPBBiUCIA?h|*p=?PK&K?7)dahlpXxO(ao#`k`txxYH{7p1F52 zsow_+Z23*7W&Ku=bxt!OsO;Y`)EM^mA@_M14sD+r6A~J^pk7$dCq`b3`kOOVP9^im z5`vHbiA7ZKuJ0`A?(tT~yyhFny5?adm4!VsMs#FH_*BBqSszH(XLnFb8jD^P+}S&c zQtuA#mHLg1b;vhKF8_I~SQbV%qgtEHgCtVK6Bd(t7>$%eVjRo=fu=}}GF%F@;Fb@> zYJmE%xtUW?kXJ;6{~n%Ogr5U)wz2W_HUPF-|1u+Ox#=xXFe)Q#uRJ&Xc?D>f4G$mO z-rfRp*!z2Qe0tMxku8M?77l4!x(o#;N9RFndv|2*`Uj-x>DijvS^!(D-@(ifjnpp@ zJ!lR&-3HiX0F!rXYw+OUTizAq3NVZ1tRF6Gd#0<{kRt3;4Eh3Rp7kJ;DI;J*%E)#;tw*Zr{mF)cZuL^#7oCyQ>KxR=$VS!DTXF`NEWtea+G^lRlZL;NV)i zYn*Y5R;WD*xeTdMB@)8n3?=2u;w{HNIS{2h+v?Gp(b9PANLwIp!SstFsQuH%j-b$* z@Y+!D{r&dr{r1YyEIv;YZ!7O=8}Et_&q~|ss!u4IuDG%sNzi^Hc+cNc&PmVzRI`xSvVy z29VSjO?k|6Cd+v4d4H}F&>hjy5tOgAu3~6LXJ%*~@Eus5ZygQH9{iX-JzmglqTY|g zQ8Z9IHp~^0*blXw_k~)F^JRQ`AoauH6&@%nT;-N(!<4{ECvQKh)gkdy@s;#OxqkW5 z(|trz)|qQ9d&K7gmw@PwIbru<2DB|L0Vo_`fk;X@mg%5}V!%m9^b+mwZrm|1d?l?~ ztk3jtovMhboyzU|_wiCe*U1+uC%q#=e>a>4Ud{8rgm9;ZVU4o#+5JjKa{DLkT^fw`@*y)rlve!Sv}xbJr~Tz-aL@-%z_<~B1;I2mh0~x%gN+35 zy7PXhD0CvlwOvSEM?I5SndbAt7c6#wj(gPDLHhM0x-&RdWxlfT>Iy4=ywDa(8M-jF zr#ZQOmG1)fa(Ln~gu4wTq5FN2WKmHRDsE#h&_Gb7gYJ8jdv$u=5%LaJ{x?kghg|tT zF2Q%=u!+4hQiCZ7O@WsjU`ZNER!poW6l+!2rieN}+9ceSlOl>U`{G>Yvma=-9?Z^qW>qoDLi!JE)i;W)UyDiLA`&97`W@)0UciMopBZ{5ji+bQE8kr zMhKFv9_HN-C%_aIk{<)=DwY_Af=f+u5AuzQ7iKJDp4!;nCL$pLn!X5tYo_8|(CrnV z=vf%MzD!dUeLXu~vWbgRL_k2uj3iU8qe?YoZR>4-kUbz^kiTB10g|I%S?@w#6kWAC z{w}{lCMNFg(0U4x44`n#2>$+NZ|UrOds-eRFxh%NNK+Mg`-9{8&uz5R;qi05$;P9} z<2fLp)R7i$6{L}vF?IgW%RDO_czfGq5_AP9e_jb&MBWaX<}Lw6rRCYo!OE-(tBu(n z{CD;M3u|{cd@4$7b1EfSYo2Tn?WbTLtbQbHwP135|4}>FlvUirSgC0-H zF&5G`3ON@jU4 zF=xYH&@zCD&+M$azBtR?qdjCJo8}itENRyT<_X#`I$78{svCuBB{@ROxwgOf>c9LO zF9gK8M$Kht9I*=X2=@~ZD`K+_Xp^!-x6hiDAJ`Rr;s%_P=vd?w^a!h*5aEr{o?&j( zC2nXj(B+tQKlfEIDYvrY9c;ET)E*QS2HF*l}>qE7;|aj1KHb}-Q zizPRm5`HFet!7VA?K@Uawn>dRE{INRxd0JA+p7W5p=i4{QsDCW!NTZX!#eHTC-QB$ zbNTUZk#?Keb}LbuEyAHvSz~BX(5h#L*a<1BxVKj^51>uK5aXZ-3=gz zVphUSn$P;zcKTm{>%Rl4R}`zi;*-X97732X45*Z5I9a`1Tp<%iziNO;I$m22jZ^N8 z&N>_k7!UxQ+e1wEDDa5rt8}h{%~DQvKcw+fQLtrx38h4 z!0j)I%V1b-T2pmT&+B2?A@4p;Nl6JiJO$v#?GC&{hQ8+{$jZFGvlcxg-IW|A3%;9A72~) zm6w9$iA*O;OG~G#yYldWQLhuGlFea#*&o4?o#Jz+be1|0@o$iKiby|b)E^$>Pfk_m z%0^!pyEBZ@49At!g_Qd(%$i;Zn!8{9O-`VEl+<*HU5OeK(cBrZYAP{Cb|CykJs{$O z|F8#KedvJe-siy+Xu7tR^ib<)Gc__loD^3FK;@o zd3ADJOJY+}VO6SitUW8cZ`7G}!)*d*-kfWKix|Y1zUSlbHT7pmyH8m<`b)X)B7VM` z>_M)e+m=&hyKTN~5MS6F*B#1PrNW`MnxRZaG#s8N3%hx&7L*ZArH--#nT{S(GK`wk zhsMXwe|74wNH<^6GZ>R0&lyBp=2Z3{qkGw>Hl_KV^dnj7aQwMq z_|k)#{dRJ+P72Sth)vGi@v&hvL`FGZybIl7b_*W8817=ZieWVzbZZX4!259}UwU}Y znwxGGvgj++s=0N8sAx7AXSKmoG=a#DO>@dz~7Korhi9(nP8IwHPXjc7!Z!ZkNBo~$WAc&?(PBouUS z?(dHu@C9mVc-3ZhaJ&0F_z+wo;L93~rb|bNnnA0OB zC86UK!RgRjQCUMF0SWPL^_c8cN-Oh)KVz#>p;&ZMm=!W&m`S>(3O1ZmQ;LAi4X}DC zDX9Pz)cNIQg@#@JFMxSoHX`lVpOlygJX*-e$vZPxHbsP#%ql|M?fn5&tG&O!g$K@k zbmcd|Ap?YE2L}Nkz}V<0FK?U1akJCrFG6%nPP-l(Q4tP)h;oqQUd56fGi?Pr2Jgx$ z9UU$0=vY?$dSBnCDKj^HMysZrmX;4|Nxgnt{2yAn8@uT)ic2ItBN$6uP3`U7+_2F3 z5@9fMV#ISvFb!kS5s0!Qn4!5IZ@c0^nUTm$QpiO4hCy?DGcf5;5dkpRXx}Kb7Duz? zI%zf1@jFa*p=Uc;5(J(Acxl)j)Y=I2aU zMp;Swxfk_aZgn&j7#Usp1_lI>=#K4=B~?_E14rO44zS@xF7)s#KrF;oQY;^CGU$t$ z9RR2kY34h>MVF{dHShYW0AIn8iC(GdpE$HfLSivZyA0?F+|2-qu*X(cEtWf zOGN`6z8=&oZ|!5_6YTBm;1J;AvV%JAvicco3AKHbp|qk5`{Ewr90ztce@Xi$m<*?K z5_`Squ&}3ngY)k)+r|R7vC3mmPR?{>lLyF4S*V7jwbKT=i z2@Xq^Rf9@R34qWk=%5Rs(xgn0`H*h;sIgktHuje(^Cd@%M-4zfU|A9aJd1xhs>-|S z4l}-LM=P6iB^8$m$vCutbz%%@>b-iq+;;uF52v6@&zvDo9+o=!A6 z`iKG$(mI61Ui&t3XU2^EAY8b3+kUwo{PS|~ZSi*T=vw><^Ff}rEyx@@nHKKiNVv9i zI!)c1n0>x&`NU~CrF`Dl7*q96fz$>c)6L8oC{MZ*srwb^)dNcQys`ov+O zipmy8WJMM%9#l$jTKL5IUqU)_sKSZTyJ~qYno^XOuh|{taIDg$EM>7GQX{n|F1veJryuCS{_fxh0{970$fRNG!6&-YZU!1!>NYEc22V*zQGR3SK4%;-5BgZ6$xgc>rNo*oA1+=?lP@GuET;sk22O_Z#AhfPf%fR~@e|jNLF0mw zeR@z(8jc`g&5P=u0pm=VBq^3HJtO=fRuSYOCj#yfU?i~%MJ+d+V~7raosu`Rkr`=V z89D<(fFU)tX8ar|qmfzLPk6|8Eq21#|6v{)WPwI&2{JdLKRXzg_POSpDgWIrz$s$? zZK(kGe;LNGJ2^Ol9o&H#`Cm-Xl@q$1KVR$B>@djX|F7Ww&oLGphxm()hMo42brB7s zr^koYGSK*Cv^qOkdjs>GCg^yC@BK609zSiH zk6mF-y@5+Ic8t7BCNKh8MPU zrC|G|FlY!vgojplv)onGLCcf=dZ6PM@Ls?XUSWy zIa)OIf`@{GXOZE<@6f!2dxRy2B+t*!8+=J`ye%ycmH`0`SCgP%S7up=99-`qI57xU zde;D3TckIeFVd#6HBL#bAY)W9m3=qVb;c+kL2g?Pk3v90LIoe68MalBaJb z{_%*hOqe@by+v8@=)ZK`5>oA(W=gghhEDKY13(Z@sQJ?9ApGX<~T<1?fmexNz4 zhFUgjCLijalO0}D^*B^_wEraY?9e%$8e@M<<`uGQ~s?u^vb z38gsJI-NKlpEw;KI~|`p93R`A*w?w#)O>+t4KLih4ZNKkoxGgjdrs*%=R21y;Vu`t@!Yu}!RZgYbQ%$4_K;38}d3So-BV zr~;Lsd%*(4`}ZHo8?Z=RQEKmmsBZRf(A-Ia>90vAqqfye>{oUT>T{ z0q*9#H*tZW?1?ina_934)*48#M}T0O0!eI1iY-+p2qRje+>F!th^CLP8ARrs#@rFVGqvv_h$b8n>)Lz9MFxXqreHjLejuy%8n;)rjJqfnNl_B zJA&TH2R`OQ+XI5Y;l6`*N;IbNy&a?UHQD|=j$1g!a8UZ*Pnr#O!| zPqH}qaBZ!B+}fiO_nx6qFZA@{q&N6=BY{L0urW!}b^^mI( zf=_|h$34%0bNAl-cIMz<`sHPD^2hZF;Bmid^SQrYTz6!~Iz2lf72t|^4PgG^P7`y26Muekv(37y;N*93VKWFp*(8Y^d|02`lwqX!eM0(z-B8E8xqI0 zwKTuScH=?aLIt8s|7_vCPbW;#?BVfh^tSL}j+1rjIbbn9QuCwbyBdNzlI#Eh$%Ia$i7GFv|QN|r@QE|#dV zy(G7DTdXc&9>p`Fr{$cJiOd*D;0|Rxr#JBm?uX=zM~X+-tjdcqz{ln*v4$r+hJP~~ zo#mTEA_S+JHgkW>WoA(w-jBsK-d*Kc&AM_4fa!Wxd3&ssBd_@Kc~?M;Fw}!aGTevp zFf|?q`*?t=#8pn_8L7Mz=8OZlK1GcEErFt|=iOF6?2X=Z_6(3|udwse^JS>s{M$2;@yyTy zs7l9#val!-LlebB;#QGC17j2Pp!GmZ)4FA~!zwWv3FBvBY5C;wTTX~vi?&2~yKs)Y~^wGdo9A#tlTt04aesD7x+GvQyy$M#-=vTMF z0s1-Y>fy+Alk~ay`LpHu`!1=7d~3O#Fz096;f)Lny73;odI1fMg|+K*4mT)zAGgxU zEWBl{k;c=|e9OpSTxbAHMSY5pQ;iS5o$=|ZkTxZU~tAi zf4jW9z((2m#O-?GzjQejwC~6>i~V1DojU{oFY*5tkAEy6px%aC1p3}-++HX^zqbR1 z7KZ+ESj8C9FTrq@%bzQRL~IwfZc6DGY0a6Jmj_5(>tsibdx>*CnIOVK3kw&txd6en z!)u@!4LG@6_n#7&bZb%3(Re=a0DpSAv`6*)s7IVO*0VlM;G~N^q+XT=y7lbYf+{&?EBIW zu$xo^Gsvqj_e2x?bJ5~s$VB>>i~eTQV}pQ1;r=_+rdxJHbge2<_5dG%>-sGHnnt9* z*?P#md&|d|H)EbBls574i)w5n@K5`G^n?@0Rn1s0Fng~!tCAc6P+V_BV?gV_ z(NrU(<_DDVo3Rp-#*F}uvIaC0Z6j%=n;VomS(#6wA&C(>+G3q?Yp zACCdnUZH5fJ&Z}0oeEM4QCz=rmE04;4QIA7V>`E>Ht{_Ju{={mba`b}Z%$u}U^ zHpFw?PD#9yg~GN7ir^7E(EmRncOXts&=zM@Hh74K9Q8F-8*Ld)xkY&wSmVBOW$uUC zqD*J0qi-LE+Jfc&%{OVX>C*P3l5l}ufK@2!8D9iTUuW|^btRd0E2VxpO8G(yKJPyI z*l@#}d8*ap{wel>!WES zYa|3_iieJU(uxT|yEk{0e_7X^&4 zoOjlN-uC9f*7m{H=Fa}+*7kw+?#^z(Hh^Kb&X-$9Q{TuyKaCy|9cH$~ewYafE16w@ z;be{8{UNoY{q)aW55ClfeE}wBJIA*H|6_Ig-*(Z93RP$i9h$!7H)t@y-L$}*6v*s@ zEdI}K7=4ffxN+kPsi2o{dt>Ll|G`X89l21zy`xP4f#6lhn++f(@~dg-;J`|Pg8`@7 zmX?(rkKQqb++~IM-5kvf6c_h`q0SC>0C#OTh6@n9p`F4mx0Ct2cXaf0y2|#tZ1Or% zHbpQK3iN%afZC0~B-jsV^H}@%tY${?Q(Y~4#NWEyV`gGbP`81IbV1RN2BFZjB3p>2 z>KItCK242Chw|7C5o&aSQczr>uQd8)(l0rvk}j1be-e|m?efuS&$39)u!{vOh_kIh z$YZT!V=_jxoY~nCr2)5dj_}ZWoHG0MG(C7n{lxe66!hJ_rQhQA{yQK;R{K|B9 z(nG|tX{r?VfNhv>UV9t1{Fm)tT-yaavsU|C7)h?{Ot4nvmBmt zd<>v4czplvntD6m9|BTclRjf3qp8D2LcRvkg>5g^;D8>-?!G!a*d4e%X3&_U)kgle z0kMt`{mQ^HX=8PBS63B4GGuBUFzsjeJGeWwQ3X~97UjYNQmi^n zTjCP^d#q~G>zk;bK4I-t=KqY)>F-Zv*3jtm2!(rX7>3}lk}4RPLJDP-D*m~c=<5JA z*r0Svs{Mb~0Z*ktyikVX(xeZLWn0h4Oqk?AC=u|k=URHnS_GIlxBz4uMI%M&&q9__ z6SRUh78M(#EFjenk)y*!0+nqvY%?_`0Ddk95HXZ2eU^RNXM4cmT{p!P8?$ zco<7cYzY~$!Gn2dLi-bQHaz|i3XS1ZaM5lStO|o?qOTr$;g!^Y=@)LD4K?i@!__T4 zpv9%5NmM5{Iyq@E@l{GYsIj)Uv9`RM5leMfd!eyfa(=*!Hmem=->SrIR6X>Xrc?o^ z+gNx|i$>V*pH+BwgVK$-7O2?MobM$(i@z0HyUJShlTb@ijY(V-@uMLsWefQ_RFt38lA@N zk>sNMuBt{`Lk2SkPAZBmafGRx%N*!6QK*9sCZh~I(u{TLwsGlWAB8Jr<;-gKbl%WN zy_v$ggb%-`F`$$o7?qQ(rrM;l-IDT^jW>-ypO}=!0h%=j8p$eTwh2fbxlkuMIC&jgRMWscdn!a$SSLR0*5tS!dMwqsE#r zcIx1;nQK5yLd-Zhbrsar*Vk6x{@XwBb|MbocKdn$T?l7;_r34o&p`OkB;$l0V?$#l z8;u6N>e|-Urk0+bj+$<6B2-wkz`mn^MGmurKeqAl%*W7Jy>IjP=CmbgFhi_jNzHP{ zk*A#54hkTuJdq zBaJ}6*!e~a!7t-l zT2tH$ibCX)N#)0xp*ZNY@TOIJ9FsVc*PlNH^J4V}y4sipt5A)+Xk60d8?1lx{Du|X zlNXsB{f$8LKz=VOR0X%gNiuLpFFS{w3CK_~D(95z=gM(^_<)Lfi-v*W{ovWs+Oqs@ z1?WEQcOphpM`5{=W=!cIBH6KyRYUa}vz@~PJ z*e76{tN(u>5!1fDG4n0#+*68m^8^Xl%1H(r5%r^^=ZKfWfooEDgEnLp7 ze(^WFUjEhaYvK?a7VE`bYIi(8mEn=!(=m?@0}|nuHUXSgyVQ1yPsbHyuvLh;dw5f_UCyuCojRRH4X7(ac)rLqQ-PFFL{ z^M2Oc;$xAZ+kBGY(U1=`JI_3b#b}?PFgp6k9_z;LKn5&hxqldT?u|KZQtX2wL+kpY z-mUa$QFdV0lGk~_t)}XxeX?z|RJMOwiu{ijm9@D*Br=<<1}X{^3f`e=B`xFoYFE-Z zzmt*<7G3oeqzfGYtsq&68vc1f4#P z6&*&O|J(18UsI2f9e`zP3ww%bGLJY{;G-a5+u3i>>*sF%3v2GZNuW|VJ&(i5mwgSd zA)uZznTq5T%Q#Yd0!B{GN|7+ELiWk_Fp~HpRSGBEvQ|n`e z4*rj=`hPoXzlGyKB9Pz5QL19r*qD48=7X^%LG=UdS$hAs7Z6h4(a-_d1Dm@W8yj02 z8an#gfE~dSQtZLDukNID1u6hqKVDTy6o?qT>0-#rj1}40+Y5zU08-|kA9G2}8|X#G zNJ*KcrqOj!vZ+(lIQ4Z53|80H*0#df*8R^48_nol_L7{U9i{hN+bb*gevF_1;u2)KC%Kav+ad4iYu+@>cF7c0N*?LmRE90e6xRIvtYUN{|4R#-{DS2(qIpvaREdyxbq$goW-X@P(dV?5}nBrdQ*VPb?u zm$%j`gHELS}7DX13Y|yIBv``}+AU!SD4#gVK>H4oaxJRT&}RfZpuq1G-wNdcsJIHSI8K-ft<=cD>be3XDm6!=vu@4 zRh3YY0T#;6+#EwrdI)ZjZ6FqR`!mqI0{0BlTqRhxzhSbi+S z(B;(u{EDrV3qd~do03f1{J%=dF2o>X^E6nTYx=25cjT| z3ym5VIa+eBhzOO?S5i`#$~;`zGTfTNG?!g6pX|}FpGl2l#BL{1`yhHZ!fKq?^q4j6Q6gd*Q?8;Zt$w*aK*F;q{z`()C%Zmnk z)^af~k0fqNk?W?33x(@O@dgkEC0fv7UzLwWZU1Y27ba%5Zk*)maoZExYIR!CTGLWy z#g!)E=-1r**H+(I0^00LZjVDxJu>~5|0=})?X~|IiUBpHVu%GkVd0=0AF^gl9IdSI zUW)p@qV-cuZJdv0-{^r)O4{E*r%Rp6mQ_ee~&;4{Ll{S^Lkradip-O z*Uycv)$=+c-5>y7;qii z^!<4RF5=tm*Jz+Y(qWiMwBiUz&AuBE3WZ#qTt&2o51cS(x|5uo0#dK9U%w6&C-1M}KTYUQs@swZzRs}Bw0u}DD{FL`8!k5ko>{qH z8MP|w>f%PsZAqto0N-BUyB}3cTf2q(LrZHjH}|(O^Y`U;o#g(c%Ir8$umT*?LM+IE zfxK9Jx{eHubx__ zR%?wq${&{N`zn-Gdg94|pO!-Ts$ZaSY zX=UWy-cBu*h{X&jwZEW^&)`-hvziD!C;dHo_j>u(_BZeEAJMm+eOiXU(lMU_mHB#Q zj!Dqd`p#c~{&*$(E(lP~1E$Znx8eb7FKuNPlbw)1@7@qXp4gc-3%RRXskwtXyMo&BbMcONLG28M}~QB0NL_gN65XX_E+ z_pLKLs?Wm<02nwV60%&dti_6l49jGGIHRrH<0M=#Lxy*FINjXR#KA9kWqEL1vM@GR zVLVeib6M)mGA2<4MQuu44qj#D946pq>~9x7xa8PAkB-^>Y*v&Ai52G}M3UZL@YU~b zW>zPT8-)gkT5U$>+#1`v0QoK3eyHwNI2cMok|;C%74Z|(zpkPgBGVuFn_WKhMMw&X z??=?82T-YH_pORkyG}`^N;%-*170LO7v%hkkvN3 z>^hfGsTzhDMzVT7!>sbR^&fuR@OYIgCc*gJIxhiSr zPw;-iz;J{*o^P5Y>9+#2f@hgtWsnR7k!XEL2a`6$x<>dILx-j4256QC7*v(C`csA# z1`iXG4K=pKES3EGYd^MEVavaBZtZWmh$&{>J<1UZF&xmwzH-AHKPL+_fCmr@HAWbT zn;inK>WT(RegxU$k$If*Q05>DP#A(cOmQ8qIV1cTNI>RM&;Ge2uFBM-JMzbhP=~3X zgewArf-a-0i%ypxL&z!pZ4R58(~Y4<{7^V52}1fVE3QPi&}>O+LBQ6#gp=4w6r0&V z*ZtAxHfN(QcPm@hqK4w$>Sk_Yq-e=@w5WPs6K(zVrKNzwABUn{cj2Oaui;fc*?3oZ z$3DAlS>XxBmgzU2C6a{o-(Y^OlG(l^hvuF~p@1?5=i#;!l9R!Gh_C<;`X*M#()@~3 z*Sh}j+29nH+GrR4Azj1d@L%6vN-R_V{T(oj3W>q`9rVuv_P@>i;3~xNF+zr!yVVaV z`+Nai&j1MPr({kO#PM0VTPeWrX2X9|Y&})+D z)9qUi!c>(*gKu?R-NRxTilpSLasnE}mx_?Rfm9~qlGRP4m%wfOfyyCuuK#st2mp%# z@75`I(~5!EHOloGT}%440A==8@YLjFZeA`t!V{~J0I!gcn!a{NbNAEX_b$=DBXgop zze<1t%VN{Q!^6SxY^EpR0f@-on7p3M5}0hi{##86F=7R#$6vhj2ib6XBkIcy_2*}O80l>&P zM6+_UVL4<|D(n7&cdT^EBaJ$L&IBQ#A7^1PMo<-UFgHo{AxW@X10B@Wu@lW+hZdu- z!niaHQl(PQNQt&Gr@XWk3ol*m&oaKnE8c zUhKbgf96i};QNdYx-cvBs zGxPnMu|U=Yf2(qWyQn%CGFZ?Fl&}%fUh(&QdR^soKVYAV$< z0`^H!bJOJhcUPdm)mOy8cT~IE+HbP=Z!lYl)QT9ea}(WfB9}cw;X(DV@_g7dQ$Bu6 zOFArGl~r9;RSDb zDbRMY*6np#&SD@Sz}w>GXzz4s>vZY3*<5CElQ^^4>f;u>|!pk0AkfqCbEF&_PG>;EaP3>*l$DhQ z++T?J-xfbVTsk@1$Ba<%Dc-u;LNqz;vh<0^qTj15!qOlQA0ZXT9_Qp7dJ@xl()2kN zTP8(3AJ~e^Ufi*1=OaK<@<#Bv>8{>OB4C_tcGXpv0%Nr6#j>fozPBd>7xFI8%ICom zMId+Pd$_#19e7j2Wiw~oPVaegTe)f+K0yLB{Dyi;x$MfuqC9l=p*w&6l6;r!olFFW zV>W5aGT=2FG4AiB4fi7@8lBAd*Z7`fGzPhl=kNIxz*HdeGEOb~aJjSkZA;J4Fv4SP z@JW4m_w)p_RrJqpIEL`;eDP59MaV7~2H~C1)A$eqx=_2Ckw!aFGwjQ@|SuK;E@hT|>bPHBjB>?7jAqJ~ox6|6&0oUQUN#7Ri?xU`tUIW1(hh;ghw$ z3G(e}*}N)noj_jHC(-?8!SE&QNCKT92-Y`Vl*F;tGOQ&6NA|Bj1_D9L888ajuGwWf z-bX>=pIw^#zzA_n2gi6OsyKw0Gb2tHIvxB?gV~9^B`Zgn4+|lJGO|aeb_j4W62gc# z!=S;8qM&jS10@YgNevEu7#MyoRm2`|3+}sk3Wz z)hJww;^s|QiECLIi4B8M7nsLV7X5N0S*IPHS6$Z?@O$q#|L@(t@(4gf{@cguf4n0@ zvesy2WUD7*Zy=%OYOCja7y^eT?6eJfC><-0UU@#Y=qTk7v2~$>mu4Xx;aD8k)TDV4 zRXU1CgqNU$WLx4v2$;;G@fUWNv9lAROE47gF zliR)jKK%{xfjN_*j_Qi`N=vKFZQiH$2gmlyF5=e*yN$*we@4jXdmvp&YJb#Pg$pZn zyOT12m_(x<{?pG|${&f4D~g)KS16BuTenzk%JMs>gvw;%Q;^;@qgH)LGqlfImO{ls z`&Ry}rx0C-&-kI|xU@fq;9y_D_lD=k-RuJVZ8?U~GP|D^Vu?|R&tuPlw8Q;ki{&mL z&-5kSw|b+^=VJA_UTey*!@1V#>0o4HWc@~8fH8k@xkA5#Bv*L^Awmm^pvS$RrF|kt zW7E&_tDi@uW5?srbZmwHl261dj+#QKCl7rCPdghgmxvG>2cLkTKqD8*?!Mg@`gXp0 zAz=>Q*^i4KxkR|Qd2>g9^f{DPmj38y5W-iGT!$YD6#hhS)C(1=P@py$OoYfA%rmOg ztZg0pAx`s_n!;>1Wymd4o1$J7Og#vVj(PEgJHrcGJ5p0^(>OH?X}&_zRGjpt*9_Lo z%VC>CyORL2H%3>U-Oh|5IAhO6Ouk~&n%-LaOlmQAZfqHk;}E_M7US^T23u~)N3x=U z&^%+=IHYcY;THxwLMVH(-*o0wTcCtQ28}SAu9T^wYFOm0v2H~o1&(yVf&+pdjPUUn zKfTXkDYidqe7bVDF_Hss%x@sHwBZ9=wfwCRi9G3*>m;=c^+Tfgx5fw{N~n;uD^e8` z#03d-rD(q4ETjJrlBC2pYwgQyOpD@DDllEf;qh0-PGk(_{tjMBzT0#ON+x)zA{ z0ZC_EE^tFr&HK+0Ka|m}3Q*MHWP;{#qHg@nkfklt)Yl_Ov!k?&>V6be)w(aGf#Dv7 zobGxl<4FWwmV3Vg4ZqKxT11?P&-M=GE9&`1la=dS$!gt6k=KWA)|9jDt1z4BnJ9g0 zoIg+_NDY$Bv?+|p8t4;5TtoT<(#a!8kc_Rg=CLq^yr7Wjz^^d9(YiM3(QfCS#y<4D z87`shm4Tzk-&~7%8cAwMwD%#bG=5oD!u{_$Q-V)rF-t2k5F*Cq6|z-q6%IFr z+b`Cd;g*511K2>2+R{i%OI7s)YJ>{R_-v0e5fj@0-x*-MjZ-rmjK=qp8ZFtU(?Y555u?t~SyK}U8Y0rw5#=0T9#AqE5>~*%b z8-V5XrTKo5Pku0>)VW@ZbOzHhgG!2#|H0)E;wj0cN?p+&oRq z&GxJFGp(b_a2^T;t;J&LS(gd!Ura`&r3LeA+2 z2kj5&NAD2qVg2Wd^`b?~u##^1I!1;L9kTu0|Cn@>3Y*ui&8M2hRK-|rUuJ({cd@+L z=I*<;$jqG0%kADU-#D+ew2-2um8zbGo1RLo@C{RHRGR}QI()+kuhz-KfHp;D#Mxj- z3#|tg^KfJop(tb(Bu@$NnL!khgWhgEK;HNGbaOL%H!(kO_cZZ_Ty*esR!zrJU0rHh zX6sPq2(AXRveKiCZnYW_WfPqepLMSKR<%E#+y|X@Qg%|RQ6{@4yCS)MQ}|YdL4Fs-5RxXY+?;d8-DZ7Cd!dkN>@?JR3Ti;Z1Zy8Jfne@eM^e>A5qB)<#{{()NAhN2%uwM{0rYX=5vZ4d6}t&WFXe+bYY z?6>MW&1`-B6#(ByiY)k6avRYlY?F|0ywezo3%Wo-=(jGs?YHhYy_pYep8xOYp3$ z93|<&AFqW45)8jc;&t_Rn8bgtFoG0ZR+XKRL4Ze)mzg-~F=dK%nJ^>;`>g1^M^0Dw2 z0q9-A)6)tD#zJQa7I~8vazO8|qN4H-TI8hAW(K-$z-$EMX~l)qBqo$oP8vAZVnxqcyTE4z~qbs@_|7J z86Kz=*OHQw*B7|gEvozGqUm&iaE85N-$`Gi;=Myf|OoNR1n*7!z)G2;is_9l*ND~Vy5m> zlAe_66A2nJW#g}q^!WvDFsEb1%$$W3g&#fy*1(RK_`E)ilD=;!eSNrLbnLo5(CU7; zum1YS*64LL{pKn$4 z(!hsWYayICX^BMmF1H-z0faDOUgTd6c+(t^x6_h*M9zIGv>)7y*m()i20tW9I#;#; zz^$RWp^m1GF2H}5zD^DERT&#@LXRI%nb%%uDXVo=I8FP+oc*Q3)@(+5aa>VR=?>-I zsNMT~_Fe%OY|!EL$MkXX;?#f10d<22@02u~Bs(KF0|0FD@-uZZa&Hodmv%u5Eg8?YesjzPz4|biNCe|lz5)x>)@wgZ1CgE|p@oPq`^g-a ziW|PR*X3*?e@sWZV9J;vYxW4Wt*BJ*icKMmuD>`r2;?jzB?jt#VL* zLdDk7>NN&cF_yw<)bJpttXiYYg$Hrox+EBxWsM(9l1;BVaxFQVC0g6m{Y$3l^*y^H zWb(Ct0TqwqrHy!@tRS2c9nUI7J4r0%ccA1Th_MKKGmqzipNw7=(-5 z`yC-KXR(Xbqh9uz8{}#r@+k${rmp^{j)FuRS&=4N9q{S-U&prD0mL&o)yE~-#6xd0 zGc#Vt4FXcqDf4>x;G{Qu>>QrHo=5*3zC#)4J`2=V2YdU;i;Z@4ZY8Q+_=TVK45x{g z<0g1_ll0)AmGP}8^l28XlH2*Mb6;X z>glV_uB-%&{P`(zfAkBS8ztFivgEA4*e5Ra9-t`>hBb3|mtrbAZD}2o%&3*s$H{qAzpK>N8-d z_I!g4y^V{Di`^tZLzC1a6mm#LVCvL2if=-d@dtn-f`WQo4_R}B{0|1oIIVu=0N6qM z;X&0*ZSDSkO5bMazPR^T6`_uzH#LSDL{zMylCh{*6!v8_-29jK?`UD;#tRd88W6*f zZ2a(|WTyjM5OE7XA`K$a)yAPh`T!(2JKk{C*=%`d-=LO6eNy}QV8lA@GX5qW5{$(?uYT6WWK6!gaq8rqV{_Nz0nDFLWc5^T*k-899bg%4< zO+}Wz(CC7nE58%1>7r|&`LBh|6%On};CK5k7BKA9+3Qo>2Ulil#OEzyA)I=xzN)5k z_91dB1vhm;Yrc7@`P_P|$!h%KwR!7Nbm=dg$7XZDaF_p@$K?s&zSM-jlhgE)R8zi+DX!j>USuFbzmf6+}Kyl*og1& z%eYbi8aq3d6rB~FRkBLcur;$jqrRkXppvXeRYOhx=U23J5w``mz5BVIUX#1~+4B0T zj-Cd9NHG`zx;wO3WP{UIkH2HjYPd?d9bUih7B~+b^^Y#vU0nALwg-By=C7^*5#Pkf z^xeqzywjG~JBs8fKt*hEKR&y!r)B*mQ@UF2)vy0G@ayMcV zhQ-yQiA7PR7U{B3JfR5Y9I(uk>4L&}Ka^o0+fgqGQKNKk}MAg8{OU!>cT z0cXQPZTeQ6Y-otB^)Tqs@CBmmBW_CBR(uN9Pe8whHKq{*hh9iNhiekDCk7&*U}P)y z6hc^IIult&O@At?OGeU~QtzMFtk*T{%9O}29e#_<3- zZh>ed3Hn!)K6P@D?c{-{&dRAhGR>2Ozlnovrbhf-Q; zhQ5u8Tw03BdY1Nx4P%-i7@~mCNEj3|2C|2!U|5-WJ%*4rQ$%JFB@iu1FR&UNm(rz3 z*we0C79)Bf)+E^DcwU*<+uLijeJIZFy1G%y57;lpRqFEpeK4svr#AOP zNNepox54;VZey1W1F!*${nqQKGPf(wi;&b1 zM4!=w1mt)WIi8k*4|=Io9x5;*t>Xya{|G+oj+uO*N`N$ugS0U)%~A^$@CrfDU(zxB zjDi}g-{y9_H;S3MzztO9yxj9}CcY~n>U?_xraQ7DDD80(+IiI!h04Bqul;DGQfOWp zxqRL)Z_f&J?!;#mp$D}znJEyOxG$8!Sv+KFG%yK)qDkicNHnm7X}ZP#mZ@ksEFKSi z-5~YAc&sWcd~tHc#KrA3u2Z!|tvrhOWwh4MLLa0+U2T=+wdGxnT{dcJuW5_F?Co#j z4=<0FHydq+!JpbaJTEpkTCI4WN;cXPHyUi0N^%7SJvm7Rvf`b!Ks4VX?8^e+e9C&@SK!DC}oe=2!IR)KyeTP-L8K z<;xNX89rlarE4`WG~3&+wNDw|o6jReGP?xt=NBFGi0T`!-HkK_GA< z*u-VOve{(sI$lze6)s5?KJsV#XuDBwiDlI2wA<-=HLQZSJz_Y#E>n>s&#uxYu)3+q z!DbYN@Y06#qoC~ZduUXs8%#^RFQ%U(>UiA0J8|3pR`f5um1zn3?ky`8id5}>u*|yb zh;!i*E7CKy`~YE)$c(w;Ch$s8Uot+Sg*uCWfdBu0kn*Bc60tBeHgo_vqVcZGu%p$I zlF)l!o$Y8p5;@a{A{MA;y78pSGyhawiHX2bhM`7;Gn0=SLyrL|YA(Sf>&il+KX)}p ze(x$St~}(Alm>;eIIq(9C)4nJRG$%`Pi$1}l}D09G#=O;l>zlXb|A|}2rEyy{rF@I z6UqKT{oX&o#Kh!_4@0tCzp7n9qxK~POn0Bel`z?0k{F^t8=WV$bWz4iPM98ZjH3lfi$Of%0`+d*1 zT3|Oc)Q72G6F=r`!cZjX=i!LbK;7feCc?&`0>WPyiEj`zl313OCBQVY+RdnI*5AU( z$=bt#C3~`{!FYzO8PX8L)fQn~4neM~04wdNO4MZVaTw~iHpLJ{@0o4nVX7jO2P8F& zY9*_g0)LX7FtiM73%y*ftr@<*gPH$JB zT@~t(nDa_3!3Y}5>Q8rDWMyVNU!Q-;R0?=GHTv_Sl;`z!{4>A9Z+~d|ite*(?T=Jd z?H$kq8w3V}fTEq~K=50sB%74FXp5AY6(4d8`W2)CVSxC14Kda>0Jp!J$(|7neP!M#bdc~N6Nm3rKC zr!?dbjM3@XZR#Z(wdwUqQu(LY><9ae*KNR@XWjjMli0VZ$+4`uiuE(j?gb8dcRpSx zx7X=SETa``uAuY9UM=VFmcXCI?4MhZ0jCqS%~Ql5U5}B)Un6_b;*dX2{bJ+ar%tUx zAvVbNyx3}e{|km|;&E7;t+B9rjT6rHw0B&aRaVm?QtD#|N>IQ)1v5EWZEg_DXna<2t^n=pJEp?Rbi%+b84 zgBE>@wCDl$&sZBX)sie5u3-dju3%^7ggOkYG$c$I z|LW2q^=X7X$b=+bGP_7VIDad1FT-rxAUqEs{`c3Yg&ZbhtA98-VX!%wj(uI9qqHS2(+`v45YwH2OJv` zc@pW)-;uZS@QSL~eS*QRl3^)&xveWn9AWqt7(JQ=6>2Q`6%kSt@a1sD9NICwXs(wN z4Ujg5Mv5tLFy>fdLou*IBb13Qh7wehbQEV=d7GC0VVB}`ks}!bGzfetkV#E+GHgo; zdPxe^0XjuV2;dH|=*R+^su{ld4T9xXpc5xQ8#^Wub#8%}+ME-Ga9SYlUTMOeK7Eoj z{5c+8L~=<%sBthD+O2t{$yWGk<^l8-^tHjUC+^>Jxt)9ral4Xm_>@C;Ct=tv&z6r} zp{HEc2Lu8;eGiiC;kT8XApTw?G6(T7S$y-k6wuNXp10(r?u z^R~-7P$%oduMBEXl(kbWc`ygs#7A>G_#cnZ82Xx^y1dKXlp+rQnxDWe7FAT9qXQRNWoM9Vo7R z^wxhM!{PqHlrf6@w}ys>krCr#@Nt?XONShaafr}j%%vJ1utCZLY&RY*E>tv>IKU2R zk`)l54kvO$a3Y;ToCcvSLA_+-Nc8k~K+@I5Q^rH0PzE19xL09^ZxEM2T;08r>?F^t zL2vJ-5e*}>IxM5eP|iql>FBEK>Zs}%XsYQMYH1m28L4b3t88g6ECW;v{k3&~WTCmV zsEO79R3*)}qHLl~pQ3*>U|YJ|SGiYBx=&7WPkz$)go2G?UDMXk*3r_@Q2K*UB_)Qs zs4()hrG>ahtE<{veR_bR_U3opOZf{BC;4#j@M)5mn3=dLmN{}=D^)8MD^-$0#gg{a z>L4A*64%n|z_P^hCyf=&Ps^WHhn9zjxM^wW*K57*;xkSSzz5b-`F8Ee1#k>5&g@p_ zRhMGcD(T8g87u6;Hg*+`_EnBHr4FSm6-)|gIJD6yK;dIMW~=M8)G`0wKr*{HF(@enreyoX`Ky8*#4#bsyXD@{KHMeK0%Foc@dW2Y?a7zFWtBeE5!m)y)@Cn09nPKsY>H~`PKBxM4b2hAc zQw=bTdtwvn86dl33htlL<|2s~jC30{J7tUt*mainM)gXG+Gk@wlcv~F<_kq;LLP{` z&Ka*(8d0Wz0tZXV5rsGrA#8hb2=WLlZf$+^@ds=C7YpDJYU^kS<QK0?)Ps~hSV|3dM{#n9iD-wcukCXI=~oXe7tOMu-LmS zQukO<**4W#B*Z=QzQc{C6F`38b;a|?j}>7ZKORGDK=-uV^aMY2R;GVes4Cv4jzOtA zv~V>;%3{@r!y}QOA!dL|Kp2#*Dc}wk@T9G+;=mX$<`+x)<|+wfmW9+n6Vre&JV6Q{ zoQNSk_e`g5(V6k2-&~!Xy!kbB4O=aSf5D*=4Ft4%T-rG}(9zSQhUpI{cH29yLQDmb zvn8I{`+QI#7)fd;ZH6g1r3d9rhX83F+T5o!LwNz#J2SPckCMp8$} zWz+>Rx^OW870>@D#&QJzD97Tm9*&I~w#|U@pmmYGDho|b7QzIB4W5;Lzvi%p)agc! z@~pf#td;cbiZ##oZ`j|iAj$~Rm(9n&PHHlGHgX)fL-N-(Id?|P6ih-v9X$yY%7-ne zx@npsx2;oeW7Y3t;N13;FQqnSwrg-Ce+WCl`zq?98G z@PqbbNn|OlP|ffLqs%SjEY#e0ONQd?&(}O0S2}%9!Qe^3CWqD4&Dl-;Ep-46 zQUOHb`noAhEb>^`A++D*zbP<`GoF^85T0i7a`(Hvf+yP_>^mLb7hfMdE`V05s94rlNBgyA-`hO!uUrFQ;KbwZ?tUTev6bfk2)tSA5d16r z*UJs;y2>&NqbYJ0S6Pl0xpf%ZNTBTHg{fkXfP&#qcEko`Jp2x2>4#2}ei2PZchp=E zjJ+-M%`_zXR}m+0E=t9CU5nYN?U$|AMwE6hUim*U+d-I>weQcq1O5=L2f-{8y1K4f z{Mj01%LO6{TDW6C-u1FEO5{eH!s?J~Yp^>`v&K<*Rxk89+{gwAjrx{pK{%7hg6{H` zcMsc$NwI*gHVhZcp0wdKa|49SL+j9{M!7E+3f}9waMrp2_*|=j#`Y;1M-`l@-?nP* zj(TtjZzIE^Hh0DCixi!i(OSc7nanL@R7aD&MA+NVMa~O*Ny?jBX+LF+Vx(tz;QO5z)n2~+EkbK=~RB(O$aRuArNbHK`P?B9}y3T{;n@b|2`q|1u|WQW&C#^eo_?uF2r z2WzC+Yu|<^;wUAwx=sZK2L}OB(u}ow6H{&N(z#H=BRZ#IMrOwSAPn@dWxWXGQF3y! zghW4}E2pG1o13furZ7&jSxML#8^e-)kCUE8^Rq-+T1Lg$&CTwM55bPF8q%Z#XiKSh z*3}gW6Em6eyV-X$vt7(&pe2ESI2?{n1P4=aMS7jTtqa8z4-E`VRK5`&cU^Msq>Z4P zQ|`6!>(BB>-UL(MnHDrY3cb>yq0bct6-%Shqdm!W1!Um3tIiqGY051MH z@+Jlr9LLr|Gt29BzL);KmuuY?r;9`3ZL>)flFUS%Mr>K;g~2$H8C)hX(5LKJ9vDg1 zwziwK{^Q_W!6v&+eJw5ASm{~($_a0`lzSl~&1i6Q+~{u^v*q@hBuGwaT16(rG(@J( zI@yO8bfG}rT*a77YQ_06^t&8u8rLuigr4M%CGBTz(hOO$R2+iY07WX2`1G{2#rb*3 zA{DG&_(o$npXkh$nNS$IGIIpz5Lqr&eN}ZeH9a*0PGBO<$;l}w7?EZoeFiE;;8R3} z;DC*4Ohm_oNTD1NWr;|vhQew!&HmRIcOao{XR@<3CCFXkB`pM7Q$ca(c{A_hk;nv@ zmJnr-C;v+7YogG=+N2bL3BVxjzK1aK4wtw}z1(#V{t7asu!Ci(_7iJI?OKG|M#3|G zfC-iMSAP)8%jX_>d5XithvcHus-ZNhg?$$3`ay^EYhp*ag-S1VxQiRJvjjXHR$Fby!NPiv7b|r#HQrO3Rlc5% zD{~6~(PQ^u^``B&wcIj0yDzEx4*!z`Mn)HM|Ia`cM)M;CGUwdSa*+s>@MO=y-=aWm z#Dz?7$Ar6q#WSK_GI$F8j#iZlliPc~VpbN4-a8)HytCQzgsG9Jow7G1)oYFPRhb-D zkIM(`+%n^eNAOtJm zX$C|)O*dVW@=CfbvAzpaCPm@?*n`6z!k2>$?jJHr;oTA_X68=>bKGOGbua>vwkKQc zNUp|J2jR#yqnSq#c<7IZa&Z1gX4(n)XbQ zVZ)|IQc_dH{vD&MHZ2PD!VkajbXfK3>h@S$Zt8e|xdU;C>74#R^KY#b!64Co4rj|J zIw(m+{~FFf9mt!$t^>#-%%H|`5iCGKf!tWd;w9=R7(L+a}KX0)=4MCSdAm8oGLr0L(M^9p} zlU7?LVc+vntLnmi+Pv160Zy$vuNTCp-6?(Vw`0bSUv-~$l??pfjw=!Z-rOFy{{DEa z&O7=$K3x5GA-?);c$oG59258bR;j1@?`wDKo#S7#N7vy98Cta`j7!G8Y7HfJkva(1 z_zAmE&poFhpKR>m<2uhd%`RI5iF)oT>)iifh>wAAcbq6gXW`-+3kyIerje?#BfH$6 z7aXDU07J#0N=1N3Yn&MQWq0jI(nxPLc;0j7MM_zW^tK^z{O*p3dB>DwtEi#{kRk>rSil0nwTD1;Ve zFZ3;T5=CfjXsGP!>S}7DA&UAPr~Ko5WR?J}CkRr%BrubljcAB$*trIjaqSyauY*Qj z+}pczy5!4ZaMIkMCp8 zx45kO9d7rl_kaJ2L0_scd_yncrz_^jocR5F_UTCy0FzTImRO8Vx&Z$1*g^%sHHrNF z9bQAW4S_{{@CWP9H1-Zakd>F+sa35n0W*Joe2kAzl9>yb%+6Zln?V{IfvCszZnSA2 znS;gRKfveH`AzeV$7EumpMvq}>zybgkI)5~d|_-aoJ7Xa5%?`Bh6qIWL~`X{^GIW* z85tM}fd`i7u5B=_`rHMRj?Mae#7dSce$57+(R=Rx!`!q(4${z6OyY;$Y^nM?fnRzk3 z5pcisURK?_B}S~XCX3$$=#(qO6wTelDDX>V<$~ZFbhUFS75?GZo;QI)1Bme*pgIWh zhzMMS@)Ol6w3}(0ZJjwA@{rGDxTD|Y`1=%~ML+Lw;vhVrO)jx@680(^n>#H7?o>1V z;$>jke#ycftl#jxYJ1NR2^u0nuZU}fLF=JJoam^88R8Cf1aVljN!Z2vH;xBOJf2)0 zb`F~zo%XM&VoMJX#A|}+d0VyL&1AG>mVlBo{PJYEwZ5~W!hZN+@mx>0$>Gb9HQ1({ zxtjh{+E9N~4W>s8HR8G~pC%S7xT4gy%5iJ9!$R-0$-b(z(M<33!Jc`Im)ng;gLffi z0cTjAq7XhYM6h>q2Zy^SVO*jvXUfLpljdopw?reo{*Ed6#Kx?MV9(L{rChpP1WIl524Aq*?D zli{FYKr1Kem_k-~G^>(le#!Rp)7nhs|*Yzd4h zjfI^A(VQSKTz@HcUx$f7?JYdX_riT6$o~9iIDhkPuDj!E_0->iuESqzcPBz1PN?Dd zsxU4DP7syI`Xl^~vS272T7$4x&3RAnLr72ILrBCb8u1yQpY_~Me|DU;kee5#)1B;L zKbrmQk>6h7ukUE~!3T7q5rU)`cHtk;Cnd7uMVMA(NH6@(jt-QFH{O+PBd02siT#wjZxds~8^pSyxnci?Y!Sa9{xI#H8F~I1 zm4r08R3Idw`7aibE9ki}KhIFiv7)$`e1r+9OkgOGtJ#$=O9jwiF-Hfkrbk9bo^D)L zO{nt>ukXJ&3?n;0+)j)yypUG@Q*v(y8{EZKSvGI{+8JZ%7r*5DR)ZoUH5X z>JlJ^MZxb|e`Bh81Z`#n{zb`SG+#vBB@&A-uB)k8UR(2gc6nT>Kb*=D_B=RSnFAOa z0%3P&v4p}vH9KBrG4-g7`v6;}PVhp=>tM(iaDCpa1WEfj$M1C@s(DK!=nKH^1@h&! zKNr_*PKyNUW=w|-4%S{@6D~RC2nGPjZuM5vOPK)HnBVHO^SjY@DIy{Qqcy^Uq%@fA zWPX0WYV$9!>gVyh0`pbZZ7Zi-Vc+ZDgL!#`oO5&9_$>-K73|^g(D$`|kfT(1;a|&k zh@{^r(&=m7I=!}qS4}^|yHfN>b!5Q{Sh`S=!*`?+(OtqXq=+MO~|uQxV53CE=5PABLm}P#=rsqcO3uE!I5Gm{_X;=oFuX zI6EWf+CRV6q&H90+7kvZGmxDC3a^g_?U#Z&8JMo>Ka2oFp}?PbN~fy?z4e)D)uUte z5yNOx2Z*wg6f|=CA2PLD_F58RIHu7#**C)PjvUlz8kL=)=jM-nJE24{}On8g7(wE7Ey^^>w+O)jt+f8V7vFOVg${pw`9Lud| ztt$b2pF)bvI-Xg9you}mb@b2J5#(hj@$UpBNS3qH$P^)TGGdu3A-3r3AfrIrY2ru| z--zGs8b21f-%xi&*=xX>1>52j{_p%<4E^TFpLSlv&}A`_!`c0pavUUpFmy%6f`x%_ z>p{@aKnIDwtZnoyCGU&4f+Yi)1{q;uRBTP(5}};VXQbPE^pHodV`(-4<4>P9BV~um zPAf6z1yF$7>b4Qlj*iT}KTL9>84kmCx}D}eF0Hu{zKtVlhmhMN`=z<8CMZkJK}Y1$ z?N$sZOUE$G?WyHis7!IJ1zEPKbW>&+OslAAAU4c?$b^yOQt(O|gp0vhKqoGuD=u2w zJ$yfc5Ikt#hB#ztu*q%!THzX!mUa?j!+TSi@Js|U>0#OZbaEDR_-meE5fy|KlfGdSJ`^x)?NE+TYy=oqeIY}pz>GO)8-&Y zyFX1sjsa#BVhMj<7PtPcra1=Kj|1oF$EVe_?)OIK?t2a6TStEj;MBQ#?CMwn>0op< zJo9!p?DKZF?DKZDJTG;(JOj`BOWo7YwglbR3b|V^>j$}OU!oPv4D=Y^|1LFsJ=7Bn zILro4!nKdCHMM;B7w5TuR!;q^bBMAGWOvs;zyGF27vV-BJFNL^f|#M5JZkeROD!QI z=D^92yupEZTuUM%A#?gofU@F0VC=MTk0ffI(kz@yp>j)8lgIntKWOU#?{UNi`}^35 zvJ=h*>BLLU{X0%_ayGuc!~cG`H&V!)E$E*g9xnX~2mABq&$%)z#*b9*6tw(Q0rv?r zbjkfp#DSFSaj}7Nc$mS4kw1O%;z0zu9vS)(5b$x5M=KWd)afG-4NOw#2~vaus3D;{ zE>v$rk@jcHP3PM|aP4g#|JvE_>;R5weYy)h>zphl1;|@{A2&R=w|jwEgfITUK-hQer=P0y-V!^JT4i=Tf`WoeYiqtQ6D!S|0)K~qkQqGpcw)haRom*W=Y#B@ zQ#tK3bDX@qXbQJOVi}0cF_e}8-w*FH>xVamJl>YC=fr<*etkU$vJ&!zyiZORY%wu! zy0ITqp{;90l>L1y4>TRO!jN#|h<#s|YyW*10oQ|xC`?-A(`7wJV6$>?a&j^;*?g>% zY@w(5Bvxc$;si?5iKZJ89g{}C3qM%fADo29Uo1f4OvaUahP-@hm! zoEGpdo^WsI&A>#$KE;u{w8=g!x^P_zC7>g2C9Y9`tu8t~+-FtN9^v^A*qm6`dvcj)$rj{l+4Vf|t%Ug3mB=M)Mv0U-o=5LpSvY&Z= zf1j-c1q9mL+E9u4m30hKf4xM?_u^d@BQj7-56FvwCc28WqEweHGO!-lJLqi7finFB z0C-u|HMPvXxBESxOm>kMcc2^Un?}Y_2l0P_m}o%cm=+ad!cWSr<=ti{dGd0 zAOg>{F=>$KvX~RLD6&g-a%r(e8B8a|*3voO1UW4h(l9^?lPmfG$dTF_%9}cxy6U3nK#_G&5~A?4C>9rW7=#eCk(h6!#0aqx#_sKeN zSPl8W;e7cb-*r{?ik#) zjwcVcCl8K$wDRnXw!S7&Z0JVTnAg(57{L2&7hohkEB|vd^Y9&x&@AdIHt%>iz-M=0 zzc#EG`SXjDRj;r-#n-?gQLL-raO(`G*T9i5+&J`WL9KTz7WL}%nIx5>M4=d1eXgS* zyt@AsZ^DNVmXMKhP-0=)OpEL5{{*E%E_X){=LVCL%#Gac3*3#|jg7Ifp9F!+jYQ;b zni6fxbIX6N+0oX)L3NB2pBSH*I6pH}N*%jaiDt`$Lv)QXfhCe_SUNQD&#bEaGZwXOLmOR&exIo0MkLnQh;{hME2 zUePu4f$latKR+)MpOlag53j}h7RTDl8gItGnEuovJw5GbNy*F03)rCDqoNsCCBR(8 z*~iDn&aSdMVEg6%v^1iUi9xbG<;I_sIR-iY;qGp|!yA)1Tel`ND~|x5IP3#)Y;61_ zE0Z}Ve({g78<#Jpe*cJb!o0r97H)pXofDIj!EE!JU7LEmj;Ls8N}oS7v9f-bb}~Eo z9HN*o!l<$tl{5&s7zK>@*hq{kh5UDH7RaR2xW!UZG9tZUP(vc6Mk_f-#~K1r#A#Z9Hw-&`Mbbr5j>PL}eV&LvAE2Tg+p}s$U0N zv}kOnnd8mjQJ@C|1S^ebhXjigLibQRIVqp zlKM9x^1K1%iJYCi6!xc~?R`Wit5zRE`_da-q-;8iMs3UCl4qvHPDTTPZ%PvwnjJwb zgeH#B6h@SQ5{>>74n2=H&3^=*A)yw5K5Q+~*_4(p^mxvRrmyLz7=#!M0`CQ7s8SvU z>0{i>vbQ)K?8ik_hn_2_!p~O~Pns7SN_73qNtqeJ`{ecw9MJmLjz?{FiYIsnDt77kQJK{Ues|IFpM{0*4%6aOQwsaD4g-G+1QiaSbL5Hq! z2d&^3r>>KffRB5ebdPZjpoT)>ivkf~8`w?Q2rR{VI)h_B@h-wjEVn4z>LfSV+Oflg zP4k2UQFCNw2qwacDskW1ouK%K59VjH^b=FjH$^rtz$-UP2kJT7pn4-wOoOC0Dyb_Wrsxf)Kcm^_acIP^5; zjc9*}61_~tI?kV>>h|8Zs`iWZh5psm7nOJE9ojFlX96E8agmmmR2@)$K8MCj`;8Kp zHU7`J>(Z2duC}%`wYpl#I~jZC{jRP7kv4g+R@dJ!khAa=bBm!3_rEQGlpl?bAEK`W zGNV%z!&Qi+x~y!q$^JI6ACAQT?&y2G$jiw0c!&g4QvUUYg@yHHb$wOD^6Cm38;8(@ zF>`5YX*jqDE35r#nRe6&0A;d^88dMqBkTPuE50o1w>1T1({%y0U1PH8*>H{ko|op6O-@Je-WThwe!R4t|!%>>cVBRP zGm~bRw9|LX)C6*KNw%n+q~;~KeFz%G0tohf2$X&LX-;QUDo8M ze9vjrfw?CA{AoBhLlnl&rY64HFI^E*Kh5{WnsJsbVh|w{90gfr53SJxp-~g?$#h=e zy+N;|gWu9E4-YcDmsmQSb@9~7(lHHrTQDa0K)s>?m%G?GA?&jjL&zT?^m*r(wYTP# zx2C801O$8@&N6{kJ|?1^N&(sfv~Qz9Q{DLQlJ0pufe^Cpd0n9zzwIa&OCxGR&y6P1 zZ}oEkwfZS{h$Yn{M<*<3mzCD{ieTY&s(n!ZW$(CLLFG~n52?%9XmJ(u8Z16qf#xVj z3On`))2la=(Z_&}_@Q?CzM-{@TAv_!hCq!c>flK3=l|7J`augBH!<~pgzRsW_}58k z1Jo4OOFu7zwY+~bfl9cqBxu_;6`-fWh%IkO{-D%+kLe>9xx#elZYJj&Zti^i{D;2L zJSo?r*=c z*0U@uET$P1YZ<30zf0!TmswdxNRLWOQvqV4`2&uWGrnWfe@Gt4R34FGvo=&AJQ>l{ zDZnK9LSm2 zBS#f<+kJgHJF<3Sq*Y?$=eZgZa;gf%;d+%#w{XnNM9Lfwj1`d>Tdxbq=|M$$aJt8ylONnu@x-EImWL_iX4li@1WFDNsbXngWj^Z?5SGpNoMs zh&APhSOT9lX5+0r*O9LwSBj3les)d{AP>n*5e9D-WAoq7xGzsz3PRnNmz4vFnP`8c zaSRtHEt}=#Cl?mjakq}wy373?;sNb6e8@$JOcNEB@I#0Eo+%@>2x6hRO9mbtC3-dDei)|H|Ab(n7kn&5}&^^ zV?h2fzx(4LV3@z1kZ1NkWzT<$GkWs{pqgI1Wf$z?Tpt-I|5TCHAkS#Q^q!gRiQGyS z$LyYMG?y=PLoB9aRd$YyIQbnN<>sE;BVYbpn0fwg^vZcF6tO}r-B`U$icAGdPNg;# zh$kdk3~mKWO_#v8!6(k+{l~4jo(sEMnKTNctc0Qhe@#vmXWe6&tmny^C$Gweeq}2H zDFWZ>Y?@+W$w*cn31&>BqN)vQLIcE8l3_aYW*W;VB+#DZ^IaW0+!$sxZtB+LXl|P3 z8X`2|d8Of4v$*M-WVorjFxY*1kn=8TH zY;7kpN;TB7wjc&1q=)!KP1lSTOLH4%hlHXKaxHPfBE-d;(tlbw&|GDEH*3o^OMM&J z?&Lo|%5M|)`X?=~nLOM1rxJX2Ru)ZO=pA*tajUuQvcc0|-CY|*w%6Q9P;J6>^eRL* z9wIC5_Sc}5{&SNUyxHk+a?@G>Pj@h?<(H7fT7~>|D~*6#vyW#KH_(qi5fUoO3p0dn z%*5L`12hwI^$Rf!xux6kIj7C&&lh02g76ScFZRWuuBB5g@-}H;d-O%*2c(>caF+~X zzg;*!mP;Zw#lsiK+|1qIM%0natiK2Jj|k1J7a>+P;PlbmnwSQjEOJj#5ZIX z<<0am6C{eA6J%i0(K0$Z+FCo=iDG>$T+nNKth>98k5gn|VxnWF=Py~O=m(Mg@Od5F zo?m+o{#U6jkWQn-7r*1(^kxG&f!PE>RrXRJYR#`sY3Ueg)OuLWP2MEQ9yFmt2x8%vu>O}Rwm$PQDreBl~hLdVgK0ITQJ zF;Av5>%hDi#~&98uY5Vg0g=AQ^TBBb28OArA52V)zL|_-f|m1Emw+_PYfTzHkse{ec^)Hs^ZfpYj{Vw1ycPv-aq$Z(9HPLhh^J+bg4P?JDQshajGjGPu2 zM}(3R?K_7xh!cdTRzRbmqPFa8GB-p0Ti0_OGLu$Etmdl~)o9@-I!-}$epVq?A+E*s z#l=~bk1>+^OW&!e7h6~lpvfgiV7k$0W_23rn3x>x?IR_NQPlIPu2K4>ibZ~U|L4#E z4ztYx#(jP0-_V#IF(8^no%tMYDpLbGcvjn8v@yBMdxS641s^_f6AseJU>40D=!Sps z@+ZaL?k*L(Ho%%?5hbCrKBt1bu2$aTxgMSheS?}BEM#CQfG|ypL#{_-g`65BjZ}2D zL1u-A+oK?}Bl|1XP}VK=52=_C`ZgL;lmN34rgpyR$00;nE7Wus^pOV=wP~C_Mv2se zA#V`E5s6-)e$)hf^^>Whn=~aRA^^hK^PQR}iYmof$oMOv54Tvt*%I#RG1USyj1?Gq z*&$-Ah?R*;=^(KhueEX^!VMgP8a& zLRU|ZkXqqIooUFiWdB{W$(lojr7s<}OSD@MuCwt_OOQzVdjYdP4*A0Ysb=-RP6*;VkdU17VCet?E77jS@cjdvj6K|F z7f>wak>#g~M+&*{07}*PkSiALohxuR2+0*L7B46A;9rr(uWKD97;zyps6dQ?B-Ud8 zOo;iIL0tchyC$6t@wKQG6AvPk4vcTgBWn)Rlzq=x@zMz`Qsi?>>%_j>d%br4pLLXZ ztu>R%Q`gFp<4ammZ-cMq*vE6nZU#IYecuILUa+W}7e7%ek5Liu$c~c&$!?KT;SdOg zzvys@epfhV31J^|Y4|K9FCT4ZM}KrwB|j;rR_Qh~-D|hxh>Q|Xwu(To6qa_>2{&UU zJu<>bd>Sa4CD`jDvbA&o06`e$@&3Q{D%hwS&;Ul zoahccRqTRG7P^2(utTEGoNhsMm{(v_CN7C0TH@UHNhVo4(RzAnmPt4wExTH(s3?l1 zC<*?uD6}IeOJX{k99FbnEZz`xutbCdI)mIYo<;_j-9B zie=KLrTw_D&0OGd+Ue!;?dI(^pwI2~Ic7%qF8^b|?G3l`SU$2dq4&l(?iB%%qiV>M zE(TME;U~Z!AqH0>i^^zRonFWE9~Jo~lNabWgzooK5{SvuV1|B)pkz(BiL_XOMd%Jx zqv3t1WiwYZQB4nmu-pU^yUjX@aCl7fK-{e7KQ!a^ZUY#J7|V8nvK}YUoW?EQ1Lllv zz>NhI!x;h%9z%Lxjf6G1`2RjaR5t?OyqEAHx&P&Pclzg>{ePCu=b~OB0T5f}a^>@+ zP#_GR9~I|j=;gOlq|x3;7prn7=Oikw%kc zcs5Xd`AG#)?z4>0_U2`vcM)#d$w_D$qTX8~!87nN?6OP4!!F6_jT{$=1V!*1 zeUH=qDg+%Bm&nA_tT7_xwhGTDP2Krrl_h`#t)p{F&&kTW@LPCo2_S&6cCY_}<>iL^M?SzAitK+m24<{gz=)sEzbv32z{Mpg6xYGjcr!T#u~=JHCA5|* zuzqT}-=Ay?AxH9@_9&%%?242IN*HQlt+*vl3R;)EHej1sp&Tqw2(8f2KgD?jB^d@6 zCKgqK1|=zkN%~4OALMG}yY;tTXgSB@qh~ zk9d$QIOyJ{#AR({WnENxFt8OF=%z`GQe#7hI-kKY0*2V&CKxiKr!GtgIoTxa5S^#GM7Fk6}9Nfx@gAV!u*yvwmgy`HDo&!UmN_ z=wZohpp5n}h2y6$R7a)k2X1_&Xd#rYnX^f_18lHAAxg3x#rZ^}F#b@a;<9pV7R^F` zr1nTyLlnsQ7)ADDQr8$88W#l3a|vNcIuRAUd$t0w;mhU^nwl;F8fs@}rwqk*aiHw0 zIOgz}Y^N)2t-6YFnDHtYZtS1tgj_T%6Dt z%-f3_e5rFxdmAa};Wv6=(62$N6(&vx5vsuvp{@O$jII~q3quk5tr`AY;zySkmQ9)^ zRTTPT?PV_kO2I98fgQ}m+fRWt`9JO}V__Mr1VmT$l&peE-M)cT>PW)qDl)}Ku7YaL zeCdE2PfJKY!ayO=#2W2(^tZTF^)v!htf2_S^ezvy+f&)1dx?4RKhN(M^8p!Sane1$ zqHTn03eGs9$#xXNWBqiq^opLRGTe11o6Wy|o#|iPmJV!cQd|3fSogo#w6xZ67d%ee zI_z}~r?;&g=a+`k<(||PueJ%1EYiddaWq0Teo5q-wRbD8?hQel1v1Be z8)*wqFU7Nf$svfXa15X|!)AynUOaf{hK zB{OB7@8v;ub;Iml`ht_bhKPq6<+>&W5qvn90LyJccTvs^q$HKK;{$W`MVoTh(fn70%`PS?Ee_gVc0{11YBp>WsA*N=kgOUE(+ci{c5RZSy(0=f!?8~NDJeHRj|Ipr zmudGNa7p=5MB;H}l1_sSMpxGKFA4g7zK#Wj%TQd;HzvJZFZ8Z&QGSB&Aap%vq;jCU z4iqIB&`SDSw5PABGM~Vb9yO>+4Tr?v0R23bFaT{*T`?3?&%)>g$ZH2&EUR?yP0*e@TYG{LDsc> zn4^7m90fzQBOy_$8JH-O-@?~|bwdQvbXHvESky@(dwMp(JkSClHvA(?ei~^ElrVaF zI$Jy22@0fqXYu%YKSI9yynlR-9N3a*J#}^1qEw;T+RF4|e`k zI=TtgSULlG{5YO?L^-My>+;6N!m=_x;so&s)?#eh!AtR_G`9Q0stJTt(|(ljNW?%A z63d@({lie!Qt78nu;=I5#3Z{W9B0E8;SB|T8}g;|h=}rl-!(;D9We%RMFG1SA|`!B z^y_i}#9FT;WfvlPHiZh42?BO_aKHBamJAdl{e}?`tCpMlH7`#`J9EYuRsyI2D|*mV zH4j@8@}QPm+^{Rdxf<+qVb`g{vOt3Z5cZL9Y(bgh?hQkrSOZajP%1NV+-`zHQZ&~% z*Y|qVmy?sT*6j3FF7$F>^jJE>PYVB=-K?-ce_co>QoA54GP#hGf?h=VExJGptUGw; zN4KMun$gPfE8rl;=y_GE@3HOepgu3)wIgUNsv}Ht$;60u+4#FGGKIC+;$0Q-)CsUsOvZ_jsvPIf23gHKNSiHdi`G`*S6($tzV39D$H zg4)!S7D22nPz;ww#imwm6%M|sf%T?9JQTi6cf8DhSZ%#z@fQryk z#+$jL!ZYlEEZOH)an6(MX*|SB{HNkS)j&1IL8GyU3w~zNp_q=;F5l#%EF$5AuY$!thF*S)B!nGU zmC@0Wbx_n0_gA#gfM!KSLis4{vHi1hf!o&y8wZ;(LcGwK0|Pwh2=|f@A8*(VkaeC8 zm-pKbd7Vl>NI&H~@vFApVn(hwxB-Xn-nwrS4W14o*nveJ-CC9&g z`*!lXjhl;$a@)nI(v2!b$;xkonV#|F_;{_+-&fsCTt|m?@D`VoeH8OzWMl-`xjFo4 zf^PKQN0(77U8ulCk4nsf-EkKZ_Ty=$dqx|lR&haorPWks=B(mpFSOTZas7W@tp&wc1un-H#1W|ESm1W zh4L;61qwyE8PFB@eMCbtSDqvgNz14`@AU>b{#sC$>oe7Cjj@qjckW>u)F9crdlL!Z%dM-bT5Gkl62J~g7pahFQX;7Xx6I~|p&yHibfx*OlAZCXDbb>VZ!%l&ztoxU z3eEp{IXn6EcW_7CB;lqJmAsd&5xZbX9-I#a0lYrRDfDDe$C4^MBc*PdH*t)@zV_G3 z0j^g@Z=*+Vk4|-OU2k3|XNLHhxw2ghZ%qWY+n^XMH4%Ihszr%Nzm(KWvGBYC%t-tf zIZ{zeO_;>bfrd(>6mvK!l``Q9*bb&wVMvtbU{-b}vh?)+OC55On$6ARR~l@05BKyh z7cxUXYAwhyD{bBZEK zAa~Ko8rTlXJi^6REeSP z!xMc?ANas@+`xzKazY8+VE|KU|HO+)JMU`9f=pT+5z97W0j`gk+(Lr%l7O<5f@iY} z@`R^q1UOucgdXSrYbfV&D<{1+!hR-W-Fao{u3Gdnpt zt~Oa*{Z;%59N<8Fp6!K^KT)cK1AxjWnx3XgB{sGEWd2u%Lf63H*WzNdrJk-XAV#-4 zI#g+FtZZy;1sK@VCnvQU8W;zj1~|7B9}vPOCMUmUVIsr@rt*w_@Z>$1HAN)N% z9K{fU+8#G~r@dm3fqRmdj141Mz<9tlskCb!Eb!e+$sP8xWTKcZ!%v#D{>Bb3M$B8s z$;HOo%l!JWl^}!3Kqz2qes5B%p{Jmss0m^r&e)0vW1JfX9B0Zpw5c>(%g%~U5F9AV zcL^(pk(TxWFa=tFoEVQ9KU%6c=yrQJ{B_jkcoV0rz*Ii(GgTo^fUyeR{bE85iQytH zU+vc?K!P&U3SEy0-8{sH+`cG=9tcV1v7Dsrl<@%62YepK05H_lSNbTU?>TI>6CXy_ zPA1r=!x9cE&DqjH3_L?=Ug7L4CB7QGhoiWt#qW6rNXz_}5mUx@*_DO8f!vmw3=M>O zQ|M{08p;-G5)4E!bU6s7WPPtRcIaFYlK+U`|4S|NKR@0Tz@qVX!b;!3 z6D0Q-ypK%pc7uXLsQiyR)bONTd4?x^ssh{&D!Id7pDXnOo}Oznl#qz}I9&dY*w1YG zQBekjNSbbRqAx?z-z>?}KwsJ#(B`v4xCJJ;@Rf=1(5H0&H0LEuQ5g6>H8vhb0c)N` zXgN|>V_|8EF^ES9HBKhFAQykNt?D_GK3^|1-~Ks9;xymKVjuViVMjAu&KmBpq#*Vi zWe7U6(Ydw>-O}iX#)9;Jl+07C>LD+{x{ZWq#Tj?J9|1i(;L^8gRLYqKVyryJ((@Wq zYKriJ^G&wqpidReJJ1QpEu@md-(jyVG8~@d2&@?dUD7JeFfB8BWU6j|o7 zA)>}=4gG-mKO%fIo{0b99#i?{el(%)ejz`P-`COBr>w9Mp5AK1-*@%5-p)nlr9Ve& zaDk1D-Yt^(XOScOzxsTMF_Fj(F+7eH?Il2voXJvMox|^Msi_|rnWl{Z=+rMQL*K(n ztGs}%?;0+(jlN7k>J?4yaNOarjU#=E)!9({^pFs?w++g z<(*kHM@CqM2^7|_e5|M5V{`X367<}fCvfWaKGHOj|LkkwEy;sr8n#q**6_@0yo4TK z``rY>)qvIP0qv1Yd~7S1AP@_40K`&CRV<@7Ak%mzPmTNipU~S%&(p1W^T82!UElq- z{O9K85BemYyN3vI0{qup0BD{IqhE%Gb@P|Rz5>IdA&a4E?F7`}4Ic+#>dHIfa&!Gkq=CTL z*vqF8Jy`q)7?7$wzJR`xko;!gqHX~h=d%(*Y=4rg+$$|zSy`#caMy=T(n^K{|HQA5 zI_4pa3N21L-=3cz-rb#=(&x)1!;<|Cg`sXPIY@~ig#+S($SjC-*=wYOWTXqk5wq(L z6uY6;3ZD!de9B;85^13dss|fkLMeO#cToKN$76xjDJhGcqK}(Anf+;nCTHU*6Y2m6b^Wk^!T) zb#+7rxH?O~GxE*7rETfguMyJ$VcWPo&Hmv|K6V$8;Udy;cyf8qUy#}i5K~md&M&i1 za=p% zZj&hOUAsKFt+Pw_*+0^jUhUNGd;m$MjcPMjB@;2R9dIdTOfi7|@*w3UV{$ z2nSqj@9FitTzy7FILYQyQ#P7XP{2pwN5ara5;Ojg!{F~l+&^6O*|~;UnfBv3JvX~5=Ch4e3&DHI<36hwp5@Z-Vzx>VYakD1T(VXI?E;bh(9*{TE_qL zT5R2HK1qXx>?8&Nn40LjupY`gu~1)#5q{9wKy*SyR$ywRl0<5|FZr!%n$YZ&Tf@Sm zIE3}@+L~}|k7AF6c_*>E=RZwf74fXH@3{mfP(wog?T&>A98Xko7tFnjGItudt%hT} zO#}qhoPvUEZ0Y0Xb8uV;Vb1jth~dj#sr7#SqB2~BYQ1eOEj+?~iAtr7l>^$JGm|>7 zE<4Fhs@aPAzt>1Z$wK0(%7_{3wq@7G`nSHO}95t|EH9C}G-hK|NX zcZpT6fVjc<+iyRV8WzLDAEPfXCD_mr!e&v*5Nl}^(K2~=JIB2UTMv)yzJC3xtEZ={ zmkk8q(b3akL_tAR*M;~DPYtVuWd2d1!H6Sci)QgtS2vRfg3#5?3=9~9%PO|R#fSg) zlywa}4h5@DxEl=r1?1(nwpBp(p`Kp0Ziyx`$K%Eb^Zj^8JQ5rudWwWPD*`eM?{GY= zsgzv$X*R5R;nw^S411;IU1Z|D1(ZjJbFyoaV*XHVVO5NpQ znj{+&PLvX4A!cVVvZ@>B;wSH-|GS)i4q7d8KJ6c9u=nb0e@$?4nG4Gk(ITN`OdT5J?O+k&7 zVybSLl0}TWs1Okv|J)`-hmj5~(~n?3P@qdYI5?X2S_gD$e=WML^85~?@&z6MH!c%1 zV{vs&c~fuxjfgRDj}=IqW97jb(Hf;^%}aPv>Dm7Ox}yK?k(`~pK;>$8BO=E%Lg~Z} z+!lh)o&tdjj0-I6DD51}G}e}^zL%Hx#&4he{g2sUj}nf78X!S(DE??OaMEOnG-oM_ z5#^j>{1>JUQw2C_i~~jWhkp`?N0EKC^_R*w2rLL54eSp`9z4ntvuNl?q3}?1CIB2C zi)`a+{$T3a)q{8ENyvL(ixVX~?F=#1QV4@m`nRNu=f>hZGjXMzO*AUX%**cHI#5X} z&y>0-DRvOBRyzM7^3g}84GKq$N*s_3rDKS7Eh#zBA1V8fzz+aR;K3rmOi(Dd+B-Cn z!tl<~hIVGsf;e=koFT@C62cQfk)RN#pn{UrDAM&aT5AVVTDNWXIecf4N?W;kQ(B3 z1aYA;81n!m886{GVfPCjrFXSE1ZMq$s0p2ecBonm00Hztu=uqZT*JGFQKm(O&ksjy zMDbZR-?a1-4HgqEtt|kjX*3)CVbJx>{?Fp^c2;hWdv$$V8z2T`OG|emErq=8 zH8t8&k9Nb>5B!VeBhiFCi;spKK^`OWkS8qM#zMmpD${fMA|YU?s=BhaHov;MxV)^Q zpW|i#dn*y1q$Bo8W*#fOUb7H3SJxv$AaI;>_GIYK0G-$%$rLK4I%s})Ox!p)aQ6P^ z&ubt@=%)hEn#ltD6&F@6%1;qdLp8Obm?`4M5C}3`IO2|FkQzdB?#2wXm{4rVf02UY zv(7(amZk&q!I*uHlyL?|#<-x7(Y>2PUnufmcr=Pc5Sb}jkn<957!u45t4LlP`otN6 zeewV7Xa4tBeTlH=1Xnm!PV{=Hek8;Vxxh-|21LLHtDXpt5mfe1-Kt*6ODDk73Qq;g zy_x5Na!Hy6q1+ase1NC=*Qj>GI*10s6%r;DBk?Q$VtGi+Ns+gE*R#x#eb)`I!$s>VE4L0SuS!oh z8`8bjpVVi+)wT4oPkOn4U{j}VH&-tYT0I`9oMl<%0gFylwl+nCqm2$ z8ar`+Gg1u_bsyv|bm}-&(hNH$CNv=gI_qokFD#B|XNc>%qwpb8xMdeG9L>8oFO~d0 zoRIVs8Vv}jkC!AbOqAab5sBR<)P`V$V<<2z2o`htqcXTC5{(v;7`whC*$>UxE)`U9 zmEMyA>q?9n0Lv^F88}r)bDc`#N<>XKNEj}jDYlfxJ4|S8{}WpLfTdV-40-R62SuD( zl7lO@?jw3#BcfZ9ETaiHWCU)823nf-4HkwTE1d=f#fE)X+FxZhV>D z8tV54(2#$=)pi$|+S@;0?wwxYFx}juFfcKon>d*m@z9w$i7rB&C4ERl`qpf~AKIM*=s zTQEUg59_B!rel2{2_JR(v+sKmmhtHGWZJk~nL?fa<#0SNPmjEKCn?bm4LL#+4V@Rm zbcvEIe+<$}%6B;QriLU1j;3eHWYp^a`8IGqejSMXJAm!qON#H(?GIrX;(l*T%pZAf z2vV*|i8&)o<=t}!9Pu^yb}`XrJf7^|9zzjE-G?Jdv($|%FO)LKTJQot{OFoRL2&{T zF4L*&QG6Cgiaw-qB&};qkznfBgFK_4@kl z-pcCrA>qf%)klN-f5P48Ghdvy@?Xy9->!=%-xd<;o)d(DpZwzX5O6<7dH%MPZ}b}Q z3cUQx{fC-4pwhmeVF}^Q!Df>Iry|KW+LmWze*<#ZFzbzJgz&Wgs#t#Umt%nL!nX@I>Uqw)PO%O$HwInK6gjl23S-thop{RKK)l z>#LeqcVfrI7A~oRN@1wzYiB7zL1gvFeQe_$tNqZs;mnz#)Slt%u~lJtDTmC+)e;*=5-$QWj!G z1pyoDpkkbIw*;990h#5+Lx4F8!j?uqxC+P_Jv=Nc z8=0u8Dk?JFxKf|21UkZlN?L|V1uA~wzQDQoIOR=%VKiD+R%Z3@FVF{ZrMTldIl!3c zK-}XE#d29hks%(WK=|hW?>B@0^Rth%sLKs6(zcz)#%DyR%SnEj#Kwb-n3!OSQQdYB znd)XH=TRd8{P)4SqrpKFRm`Hl4Ff?n!}TtwQfJYbD(E;;lRSBMiO)`=i4=oj? zrQ}9I0W8|zC0({bbKOm#%7Y1*seTx!fI8MjD(gs)aE@Zatlj0!4jD>p^7TEJtBp(RUcSa&e&&91 z=n3)4xqO}%*Olz$N_hbCLAvjJ#pv+Z8qk7Hduc1nafNTX1`Dr-(^@H%1+5`sUcwBg zLc&6Y$w;82RD%oAfLyxFpoour=LgDy&(lu+S7YL+?rx5ThH?qW3Gch*?XVa?$a!%Q z1#F|^d;AZr>U)7dL)iEAdpldxMs-r9LOaD++wSq{ zUwtnt{_o2VjkrH(8Fnu2Wa#|}8EN_0d6YfgUFo%*(E6Ok)WCF_2jT`DMxck)zdF_usym#80a2 zOY{2yqEvAE^@(+5nAa)@QLX0|@xmJAW++vuqkmn<GRn#tI#5K~kazX~Qyk3M@^srl!<68m$k2-n z(1_ef$_#GWp63RnbJr;pI}FKM6>pwl3Ag}?#X;hj$B_mrde{_*d4P|WG@Hs%D$U5q z5LA9$MytYrb1zM<3p<7pL0{x)|0YgXP1Z2vs%QoD%b&QuNbSt$<(ze)q%Om1e7%T>huUuTH(Mhjuf9 zPivFRZ^yMe)WF+J;}Ki)5z8u>DMaI6GzK9G)MdvrbX2VG(OiV%`&0MfKL7E*yvzRH zFRu;3S$0>uA+D6h-T%CFAL`Zr5V}_0qj(gQ&;u*v&7CRCm7>m)BW%JyG(Z?w%;Y6L zK92P>6l+vHNP;BlANhlG5NQ_J*f=n0U^00N_M1k)1eUDj-%z{7XSM(KzQx;aV z;*o_P4n^Mf*-&1F8TT>zy-7j;Y-VR9D>_95|J=j)R5!gZAj<{&~>?Kja( zZ+Cfk8{n43BF99HMOFDy@^W%Iy&f*3)UE1_&MJYV&+2;dmZ3Why4)Y;^O5aj%dOox zmx3Ga=*EcNE63Y|+k=B-azs-9AbOnSXJg=fxq`ISYNh_hqsilOsQn;-QA5X1x-PTsbM6l)r`}Uq>}7&a<{Pvu=!Ni_x=0JI6FIg{`30!a^7|EssyjYI5LqE zB)SSzF(xi9rnaumUgpNm*3Q;mex{}XP(OFt{+?@~5R2QZSX-N$zgy>Q zcuUbDM@1SDI5S+15EU(&*XPx29UZ@zy1KgA``O$30mfn*8+dy3(p6jX@K*`O=|%>u zah513rS#J>*h}JYP~?Ke_!uw`!1e}H<&G_>gyK?&^wx7s0cI$a!m&Gl)Qmh2Ni~>S zwrTp+z?ONIYYG@X^571v)hoY*%s+`SJpLTmfH-q`j>XUoGLq;qqhynAmgPb&NWh2! zp_n~~p(mesR!PGvW>XT|b>*tt4d3Kxp}yyKBt_VJd<PZqo8)mtQFcZ!y{ePFJiz zp(jQUc!RfA$>Y0qf4aIn9ANzvSg@st2UzAp6iSq?&Dk!9M20~jW}l!;i^O~*lG4cV z*y(GG10x&eZS5%S1MY#6@Am9>v(wD*u!OSmj68GC_2ndU-{S=RXoCDQG3(RSzaK=t zN7DcR;^yHWg?+&BeN<9XqdgaaE7~_ttZ@Py{{qFBgecf!hi6mb3dZV{h9EPo^>r1e zwEwgAI_rXUT&J=(((Amid-VSp=r9YE>Htrh>vTG3MfF`#<|jkk{;%ui!}tYWya_Y{ ztYO`dE6HBdP6J`(gAjQhMX5w$WE3G)IwFMluq1^~nWw0$Hub-zHg12dX)MhQ1U5MR zjzT$Rxvf*IO6^Sg=)u;~q*XRicboPq3Vj_yI^}RV%);bQJwntpP?WO{8`NagwzHya z`!|MF?OYmW`F22w0)zri3O!ubQloX`Yg1w1@Xj;Reng?*iPn^C@4Md z&%=l_Z`YbLwX4~kz{u}%-z?#DrMa!DDtkr8P>zv_%sN}DzqhH=ax%S^=;TB_C52i! z_wclrkDoneGZYEbm8=$I4@35+Mh4-xDOe>i2@uWOv};{$Z3%|w$GERR4!6+fTOc_qqt2j@m4|~vkiR)cj&v}z zErO$n;JchSb}O*Mqoyu@^ntB2>T`LRNTXk>BRv%666$SfVbG9aWoHG;s1miZWgP>O zm-BM~XrQN6XL!`>b0eiZ1H?y&T^__ff3Uq4F-4#f`QVl+S88QtB|SO{#1Hy7-mD*= z5a8lx&nNkEz!8YlXc_Eh%6KZEWo2d|v6`Eb>wWPQ09g$U1#--6J51GxFw3cK2iJ^I zLmjNB5BhIGv17?0#!{A?CuQhWD^P6EHI?Xy{^XiPFN6iMY_!p_B*JMKXy_<#p>kz{ z&)D~#8ca+*^wB!aq~bGW%1fXZC1PV)NA_L=9&x0yb#)2#A!uah(dsuORWDPsx@r9jh0FNP5mh~Mu;pm5)&MH8jucVMY> z;l&V}DGUHnXrEut=NL>VX~Mie@C<`6*|%!FMc-t8^S3YHDhnzc|h~xL;INUD#;fc0(}{8h3zZ>Y{OO zXLSAkV&mmc-`m0)3x~kB%)uC3aWg>=AaSW-6*7NTU)td*-1E!vL=^Ikrp^B`8#wRv z-DEa;Q6~7-QCVju-lL(5>L(_!=_hu&uwM4QL5DpJ>iSiffXQwYq2NH$YiNx2_!)~=YxZTrIUYvz3%bPadyMSv$dZtn?Wy6x2Ny?ZXiZo z0Ekl$3W0qe{`5ZG3ZM4}g#Zh0KDYhC2*9lE*3eT+B$|W?Ja$62eh%J%+bLdW)CS9~}kIOijb;Bb8lGkK+5VbgzYu>I#s1Ka)%+%D) zTDSYfJW$EZLWFn-uWjX}W|2pk|~X38Wpdr^zh3Q(|!QQBZ(kRT>5qshX%g>8Nyl7OlG zruJ~zt)l&BqOTn`6W(siy?w{4t9o%d&d<*+L6RnDr} z5dQXjnAOBibi5w(%5vQGJ|5+-1_B$ z;B(!SHpy|w0y7BqdnZ(c4@9guL1gTLK4B7=x0FdVtd+&*57HsXT8rBlA(D`4X)AFj zR390xdJBlpK>OwdY>*;}$SjdZO?G)Oil!YX^99$sTZ`R%Y!42!9u0W`RpM7ED`4O8 z&$svTMx}6$_D_7vfDd&oTf}r#_w;+$x28vIjMlhL$%U_d3U4K;0Hq|>kO4&}GkX-v zB5<+({QMk8z`gFZbKmj#AKSro5G1VdOC>N}`fXsSsivXx&HM9cae~0}9<%?&q|xhy zR_}A#se#t`f6oG#zdXnr-4FgtAmsJiSQorI`3+mxTIz1>ym|lIDPK6i#mAQKYg&L$ z_N)d7qOlk2o=DZ2M5BcjlRafE+8GXJEU}c6xSs&diI?Vr2}H6xz$-RvLfW5z4T@4u;_2+pZ?&EK zs1)jY!!ABf!4q%dRqnuUATL`$!0*8I4(JjdR;%k}zo$^E;`9KHvFGQ9D`3{PPxEy; zo35H?F$~;-1Rn)b6IJ54_1O>vp~mEgc#zu4=Gp28`bli5$=ys|-Vb zoX3l`7BP4^bcW)LJjg+in2H;I762KB>I%kYs&pPh-a6WFCmNOX?8F&G7WMEm>bZE- zdwb4*nR$DByQ_aY>3e7cG%*u*>Xn~tbXHzqQT(2Okl(&%?zi2(2d6i^P3|vOXa2XJ z^Ao;rj{{sE*_E;V<&~>{Z`T`dPaj{;rRj^+gy{Q>r!-rL+wTO% zR%^X2OwZ~4Uyt&3P+pQ=+f((e_1h*u&&CFHG}@>nt5;Dyp1wQ(c`K1crBT!wK9w~;xgo@2c=R1 zO`YnWu--=^5cX-zMg0`02dg(yIq$TB}fMhDrl?+H&6_^2li+t{fQ$U?Q|NFO|frkJ7SmD#{ z2@nbk7-SJhhc`n337d|$x4%DQDkG3U9f$mjlasQvv{AdLy~J&yEWYQR+}-W%_~mq2 zZ}dkVaCq?5d4AA@8$&N7XEPrG>qs$#1VNyM9XIkaUh=XOQZDkQk_`bod;bHB$xGtE z6pl@$3+8LS7}^Lp>oXVK*ZF5JqkxUpqa~-cc2*u99$sEn0GQ6p$;-y(di1vz(5V1P zX{CLA0bd-~ulzpUHLY*YZ{Ob!$K`#^U~(Tab~ZM)cXM+!HTARi^RnJwtX;R$sxNf2 zwe_*_Ubz#D9k^1lvvIcGKWsg^Kl@wzRa{&gi0=V-b>u@sKvFJHCdZbINR49SWUyt` z7$PAwRySTm9dY|~cCkiR1JpVcAiL1ioSgjS1rqqZ`F1@${>5fHfF@xd;!XpO3IdJ- z{`6z=ci3I9%R|?Z9CULiK8)J5Op{!c`BVP{CK^X58^%Pb?hh;N2_3h#o`%j(=bs|u zE(^~KMPWY7LCX_R#M%fpQW;DDlEZTdajJctNQOVisQs-gH4!hQwc~dJ5ky|OnP?|W zw7{hV%d$@LPGs8R}f^LHHSh%+xJo8IK-{K?8UE`DrQhY z>XjGMlBQu+g8^HfDut5rJFXuOAw&>0CcL#X{hW4eRuoEVy;M2&RA9il{R_O;{ z9aO((LFaOQg^}d^KUbB=jM(WoZ{w(M{7;^z+-~te75Lwiz~^mZukGapuU|}M*xdGq z&AvkZUBbF;h5xA(pWmZAKlbsqdYTKKv)+MzeD&MAi(`D=Lu`9J&H8GVGO5Q;-+Orw zzwtP+xbb+HJ|n#L$>5ZaZfGLqKDBq4oTt=vcYneqvBBE$^8qINXO^xc=qk3PtxqBq z;N1^L$gYwN5U#w9;=oSYobtOMHYhA`e=k_}f3fwJL2*XgwkS^Hjk|kr2=1=I-7N_a zJi!}x*C4@NLU4C?4eoBiA-KIS``mr*dsUC(2S2*Hq3N~eoMR3lvn-Drk}v4p6~IAm zj#yQiXHyP}Is5rCH)&^Mue5%c`7&DUAap&y*;8{)RkhDPq~VNw$^^#T>PtuESYE(7 zkUI$C;K0&iC$VJt$G$o9z!=hqM8s#849=lMHSkjp1DSGNxI|R_tW=fsk0jGdMdFA( zDlHvNX*GFRVsdDEz@w@mB|QZ=mxo)S==F8)>9CyWk~2X?H24xAH8`=nX%H1sMQ;!t zE{~}cPJ`_d^l$YJ;Gf7hx3ZEwT(IPiDc=|YCfA_x4NTr6AHHSWBw){_ zq{3up2UbbqHF@|f`f)T(S6Ttj^aB1|P^T~joGXq@SWXl;Efj-*?Ff+Q5C&0EQetLd zt3C$kaJoXs5kb^3J7eP$xpkG5zOJr#&|Z^w8b^Qs&i?NuLrkJ$V)FH*RuA^^z(&_X-s?f#>D0npe!O0 zH#dhurh_T*n>t3OkjKOlLkQIJcY!{m-aIl>ob?oV*TWZMn$LS zHVsReqETA7Q#_F+;PwhM21lALO(h^NosU{KcO7>ouj}W&=Q?kZ4nAiyu|n<}XM<$I zS4)#ZSM+a(DjnCY8%rJDpS`^`@C1Vo9*l3t(q3)WhLdIl!D7(zin`4fz zc4ub+d-tk-OrPbdh<*!146=t+fG2vo@%oDzk$kbG)LT*Zc>i|35^{9C@i?&XdZF@q ze{FIfa2&F8DW#@%d4x^B;kgqXiQnq5b`m|(DNDzsKhJAF)&uw-p88jk-jAG;_zk^e zi@XJ%zxuviMt=64=W;%F{iN;i{~q|xgxLjz?O|?xAA2zD@oQzjbUqAK`WpZsOM>tw zB*9^Qz4hPRCp!yU8=024mdMu5{l4?$d_(K{XZHE?eDSjAlj+L=#w_`ohK3Q58NZ%Z z_Zmlxjj$YFh%Ce-pxbyfQS$8>GAdEL6bjkN5k>K zw6RD*S>H@iomilWLVrX3vPb>R9gWHN1BycT-*p9n6VAzoyc~WBadfXT82K8_gos12 z$q}6DefJ$`U9bCA=ru}bD>97$@2?9WlvOb@vAj_a&v_Br&|DT%v5GWW?k93O;;3{O zgc5mWe%|?F(;wg&t0;ZQ1dx&4Qm&nyov`JRGba2pq$zI3>|qP|`$=@1J|r0RUanm`9LTRFs21+&VKWYsB(*RkU>P7AWtN8qJ6f&@1iiV9(6VprD{w z;X&VjqhqQcpa;^v@$h#i*Qjl9!-Fu}Vx{sFFt^Xbx`7K}Vq&6jhEelU$N|W!hL}5B zxw|_%1MYyFg7M%-@%F0Y7Ap?`-n6uZe1n+#IAO-~)mc>4B=sRS4gC&1f*ymSllcUX zg*i_s#=kArT>wvq2SXncqSp~zhk$14Vupzok28%-`GwPyp7L)<7L*zlw5lEjviDK6 zqGAXAoBZfd9`)bBKB$_XlnREbocS5Sbss6%lcY?c^jgx%R7*9H(~%m8#@;0doqPTI zWk*U%ii_JXJCTW)FMnp@Cv{ zlD^{sD+4$C^$aCCZ_40IM*uZ;b=9u)f)7wRE4sE@0T2i@jM?NlpPeq9bN2b_v`gQI zMBi)Q`;FIQhnJboyVtG;i~l`<7f82VvUU8+zlbOsd3m}McHewW-Y?p={OuyP=nKN%ajqh?cFYQ^;%0|2 zy$a0)$U{I++T3h?mt_Pvl$t0M#*&LVoh*jGzCs5LgwYo5KXqJjfMaP)*+>G^MH+0N z34hy3SZYeZ*bC6+n>KS95^cj*SL2&sUB$IXBUuo#1S-zS|@zK_{-M`jZDnx}LU zil_QC=pq4FcQGVRAbVW-TtY-MCzy-DVUy-W;RMG4;z1}iyF!zr1v83@phsZeZu5~- zHAnkh=JmWb=n?Pa$&s56bNfWxUeL=Wd za9VX&3deIHEvh<(%JOPg&lMh~n~0VNYE4Cfe}sd_M%8L4SV zw@Wf9o^u+hY!MluQc=oH6;3rz7yxt^1rQEGn9{znhb^i>_MWba3HD+MF||?rcnT1{ zsxDkVEAd3nrQHZo^|XAe<}@1I@(Y8ZOgU_`q69}$&?>VV1Md8aZfmfmntukOk3l;5 z^yID5JYJjKy`dPQv&)(q21Z%qo{QL=glw5NGlXnJ;W+rX-uqnH9p@cSfZTYenlE|a zesO5=aH?;2?0)y};eFrXVc*bU-{GPqv=kR74~GzP6h<^r6k0S0A#@NSR2I)TLgl|$ z03w1c0z&8$LdfGIqSq6G!yZ&~r7!65)jt<7Su*x~_{`Svm#gjbhKSQ+PvKc)N~LiN zJFZrHsN}F%?MUFx?+$`C(_kEBbuV%gtTi z^nvZTP#sWU*#Et|>&30Py+qx;6|-Un6t=@;_v5lY?G5GoK^+B&Rl7XY7ifk#%!I2~~4UsPjy6#D?LW{lj{< zR~juI^(Sxaa)`k1`&&G=lup20*w9yZhVqG{v#Nw%mcHY?Psf05)~F7!8nwIF{hn4> z7wen~1D?tw!|c=QYKICk=kE()`28+20Ci+a&c7$(Kcj31V(Pety$qFC-U*EYToP#L zPAI5=6R?d)AAjs%Q~0EL4N!fotBZ>dfXIV(@9kY6fkx}&-a5ecIbDfN^%laTt%k7x zy0Jw3=GYuX>O3j&@*hR2(hUD4xs`aA~M)#K=Ybv?s^iA4Qre z9v0h-Zr<28Wf4l99ZvFFPhE-lx7{r9vX66;qc7Qhg^TuEI0&AzMWP~%6e-+XIZWQT za`6u?m2`)?E^9|@8X)Wd#NMCSY4My%2BmnrR7J=-C<>c4}rB*w@T@K9tDqfl+%FDRvv{3sTi z{WC|HU-RsJ2epb`dGI3`wCgO;4?!ZBQ9`c_W`<7~%ubV_UYOGfwJ9nq6>#IEy?f+o!TAFIC%WkDkIm=dzzm1X4amc=IcV%kQ5p5BJ-#h zoKn-?a~!~D(hqY6?wAkzb8v;rd8m5~BcFF73Ylyb@(A&x<2Csy>e&%l9naFio`_+D>g)bnE@;2VZZSuRHF8@+zFtJ2{AWUQ_I>-=-AF{j?_ z`Z|zDtD&v05VP~}x_WZbvz>6jbQ0lu)c%Gt(QqGsBgUEheE#<88%ohFdyDQf?Z5K) ze;0#yVTcz31Bwr1gi)j+fSUIh`k?o8{nq|Jwe-SRI0y10b23VWl|I7U=of82$Agzo zA9`1x5>*@}$p{hUrcw~QpGX?_=f=8ZN5~h=wjWWPlZ2!km6l6W5r7a9EsB%vi?|s9 zSZUg8r({5hKdet8ZrwCB0bGSx5=sYt}w*}dVEaDVX0-0EBoQ^%aiNEoMton zEuqI|8tErG9w{Yg5D+s4Cq|_@hRldl0GR}6ctCg?DL(|%?e+I*Ki%QwZ+{BH6QX+Q z$D}8axaBUJIvX2HVRFjMQPRQ3G)QQYq?KMSkVWWd1>#2r<%zLk38PRe2AO^r-$Y4_ zKP9V0I%E-Lm_ho=fb$Oc0>Gl+UWEsdPw4UF!JDz!;@^g@S5-?23gEhPEve8g0A8E8 z3JsPtAbppjB0=WjqRI*L&#gK4B7_@ELJp-4cduyz{iMJYpPF;Zo3^6Lg36083FJ#W zU^MYmQ^-{*#Q6~|W6|H=UxJMkN;-(v47gT&z%z62pSJ3|dx=VczfsUSR_HWa>`-*QP#_Z*5L zcoW#!D${J!7Sdiyaeh~bjfygEb=vWq9G|`nhqtvd#ydvr%E%W4^;9q3=!3{0A$q8LI2 zNtiMO>oIlul67fU)zu{_7fux%n~#PkfB&A8oJ{e>Z)s{YJdN>~QOT3gvjo z50&^D!v#Qz0fx(un0BP2#cGQ@{@eL#KMJTK5KJ7C`xohuk=-Z--n)4~i-=C*b^S|& zUKu?A0l~}R-`ZyDZ|MxZN*14o#33-H@_N}Te^7qNI(S&bVkGM|R}mn1Uwx3a7u7vN zt|A^FeZqt+OQod+wAj*@fa6ie`Ht}>a_efJ^lc59?y3KB&9d(m)0@k$7s0!qYgbuc z8~(@K`QH|#i)|CSE$nT$@;Up7 zsPj$1vcr+<^$?PwpNEyyay&frqRF0DM$2Sqe3pGX>+j%N_2fCINNA`l>d_wk$RS#o$&&zwZR{QhkPn4(Ow3*Mq zs{!z61q&@8pOVp$ha`3w3%b@+z`~u3oiGi7 z0Tl?KmwW3b-O^Q8$9;4h0^E7c3E%VZNKtJKmk$42sK^rex<7lw1yBpfNz)At4HM?Z z7X!X!5AAM7LJ1?lu**u;7EGqECG+J**zv5dOAAlcu=y5k+Y z-vopOxdcOX_D`a&AgjVVx2e%7fqj7qfWtxqx0wcZj-lkhv%K*w{G7T z7{1*}$id)SvG3)6*I|#*>C21FbM|X1c2lqr>zz`k3Cl(W>8F7Q<_RPDEnNj=9Vfb! z&>lj(UnDY=UzVp~QZwd=#@U8AP+`~5_2(HY|Dxf>csqS-fZ;cs`6Pv^ScHb8P5kFt zK|`mhWE0wYXqCXwyyY6gpMv^I*B+o>@r$M3#H)NS_r-;E=F4D+&+~RyfBM%^!aY8| z5erExM0>3tQx$4h;stILc6pX`>)jWU?;6$sR)o2*-=1c19_olW2 z1zk_XGH!r0oDIl}3)~;zZty<&Wl`C7^=DKRHqP9yGF!+A80UyV(s5T#Z0P27;wg0E zorem3XB-hiO4WOgaZWJ6mCH@V*KOgLsg1x)6g@jXUx!@J{hkL0AD=o$c`W7_FC}Sq ze$M4vox@s7b76gR3Bxe+`9U0@DL{fd0o0x=E6acW`~e%vhOIfR0cIVBz$(&Up}7>e zpI~_|rN*2{%w0+8)08MqmeC-4x`=2P&tYB*U{cc@)-rfeTU?9mTI>p2jD94Qe z;qP~vt)%zb{&+gCOWne(wk#EvWI3I-^Q4+OL@+ifeYu~+!SP0-NvIotB)_W?` z1RKf-1#1$2{-j{027U)4|Do{XW96Teq<>m!Y$hlki9@(Tb!5C8fH5VemsY{rhErDK zM-kZ}Rn~7_ZN-e-vk%yVljtU;q9S5|1RtjaFJx*q7+ur%h*S0WGto-`G zIE~odEf_oHZ@7P7X>Rcp>5zKY5BaC*5o< zIPB0UR%YvD^4Z80#I7M>Zd%dSJ{*vbca0EB$pqdvQ*Ic_ z*$6w2d_`Ze<4Sktj8sccrQOy1@S!op0gcV9gp#y5MMlmryoe%_25EX3eHc75!a_Vw z@4o_oX7^SeP%5QRll(bh;X-VJfWqtfeg&ZJo~#VU-rTNdq-PF)Qa7e+uWNfR!08N3 zIrzrzufh1nhsVdKR$qKis$~He4j|TH2;~G#0~&a@3(rhA7?G9_<=&RBl>!&A^I>3L|i#RX>Dy~%jS3a zvj=>vu7>HeeQ$%$Ms<=?l7U2L*8*Z11OlW#k;ra8u}A0Kp(@2-FTamyh~V>~+b1C< zURi0rHh6n^J;ji&xY+_o=scjd)-cl3(lJ<>Uv9B@x&@MN>^C}ar=5aMKRAGT2lJ+Z zc-xp;z)GNk#h?M`G5{|fAoOc?ZlSTh9G5 zZmDl?|J9M((Na{@P}ES}jI{Mz*vl1g&8IYb+AerD`aDps@t&x(qTWbDq#|}Pio{;uYVXMW z)JPZB@6%da|Fq{x#CQ1x9TG@=HAiuLPKet~=rdXGjoIPr@1v_uT%7P4#y^11U>m-3 zwC%ie6HxAGB1WWlg0&!K&ujYqftwi@U1Qlonrt!)@+85o`C@| zDx8M8dbNXWbRRnN@Nr6dMpD@Ockxi{bqL=gj5i0+93?TtA^R;NK@>1UNoOCSI+y4M z1~G`<^D%MJ6I2}^6Deo$>FG7QzO$=lqEl)56LF=7J5!;3KRb4WtOK#xr+;6O<ABF~7?`y=1D(Ufs{iAFel1OrOS^%s=&igFnnp<=(+wfY=Ot7P&4p=%HW8ZwFXwu>{Uk>;SRuBl56_i`*nr->rHwDey6x*%PVHp6ym~|dsZ^D;D z2I#UEsO1*L!@14#6Ij)?NlP9N6xr-nUbil>xz$yb3{SemrC%QR^rQlmI8X@?^0?v% zQruVO8ef6E@C1nl=31{V8f*x3ub){LyD7mT9YVr3R(#dq=-EPL2R9>YX?mWkECF^e zyCWvMpXe%UfLi(qCRE7~Ph3~N zHX5;j!3B7co3jmCC~-l#BAK;}%CJr}>%~SI#K?|jskS^C=aKr)PyXJG-js^+)sTSH*wK^QExv6f|W;l{xB-yMh(Mroy^)_rjK1e$G0`M;hAc0-TQTo2=vFL+V)K>}7k`{kyxn=%f+5Cvm9+ z8(Y1yfFIZKpFcm+(_x`~yUrc#t?g}uymyDTFho~7Jg$=i5iuSNo||Z`OEBw4uq=84 z#0hnIptzJ$gv4Mg{81XB7Z56T@FwX=B4{#j9pmBDcd7}GtmS-Cl2OUIZFW;R@&{w! z2gDeO)gqofm6cu1%>x#WwbV| zgATTej*P_Zq54LRMFSBr@$}R;GwZ(Cl9Y`l;>RP6W85>&A03e~bX|&j4rohC!eQgo zk^skpdyX#$2KxauIV!=FuM{K3`%hy)wg+&Cg`H;k0RBD3@(=GLU9tqYey)dXK79EhMfybR-<^Z|DK;1x|D zz2zrM92^`}VxDi$P1RxHLx8tkTADQnhv!ce#h}eBG`f6BM=WMJBq~ifB$51d?V%(x zi>~7vK$}HRPm4s!jKNxZ05CUYW3{u(etqFvE2#_w7fi2WLEVxs0s~ARIq0Htx*-aP zSCf;|y2x&2%>1!wk5pMxibi`tuLW_9yr?yutvrS?;&fjv(SlHY+7u(clVQ9 z1pbvB$@}}jsP^YiKFg~gi`^*J>dKC|iPG!hC3e^4&LH4J=(KU2cMmBoEe#J3_w=HU zfk`tbU`MU{Ou@@?*IIiHd3^GHIXe-I>m<)G2x^n?;Geb+CH;EY*QO~v^^Er$j(>A4{GuM)g`&-?p_Oic+s%@m{Mtg;$rQ_jYV)}Zq*-e<126@BE=!@RkzBbF^;HY+T zY9K8a(QgM#i!xhXfx2H!Ocmr{NWuV-6K2t*MGpkS=D_zcl)|z8tg|yXI)yuFuv0W- z5C5xWYiw*kd|{SIXYAAe*ILKuVOYgyJ7Rb*C6-7y&-d+lcUQ`mvh8APj{^1Tq0PFK z@dX7rZaLdf1R#McwQfgP&lDw@_&o5@Tz&moi;cCd36sT(jBY3s8*FF|v3H%nFq5QL zZi77jwT60BN>h({|J)@G`+G(MAZhhTnx@BtgD8-~k>DR**ry6c>6J1C^lp9#SpVLW ztD(xwPD9gB9mFw?<$yD zJea}J0a7Jm)y$*#*og^0ICFfv!xFZz2clWnvcsZzYsw2K*mxN^#6hc$jz5u%%ni1S zcISg1r%e%~z~tl`od9uSXW&C7C!wItcHiYk>JSVP)wr~HJmMY6-$oz_%v0p3U#cPn zg$*Gu@*pv*{CMGRKd}7pu*G2Xxu|2#!a@JBqOBoh@5h|OFXMXa z3_&0Mm#fFUJ$zjP9-Yhf@DoelC$5i0)Cd+jTY^j zYTX>6t;BTJyk3+=58jg5@T~iJGE7 z({tNcNw1P4(owUs{YJ#}w}E1z1Y<&maQ{Vj~!6;r3wv7QP(i2N)EK0?Xyay zuAz>A&OpLlQ`h!<#i%hl7)7S1JH^J&FAo|W;0xMquc|)CfmMYH&k!ay3&p^srRl@R zMSI7V&FB4C$CyYTO~!4$+0`>UyGuas;G(1jraWiTtac}ZQJ>QVBf*PcR^tTpfO93- zb3e#)7N!$|mUnL+ool&JuC5~^5C;bac(}Q@d$@VIwL0oO$Nw~H|MEP}bX#9(v3n9c z_o;e4bxUFMa@iZ<1^nOi4cy9_`?KTXGSbq3`9hti2GtF|16QssRVJ-~!f*)Gr6JDH zn!JqWd3a^Om$=!IjMb{<9|jjsOF*CxTHPr3d2yCa`c6wj)3tqv_#^!X2Dw1nPA{oL z+v~ux3-P+gd0=#UBVfpSdc&(-VOal|*~km|J8e9AMezbqz~48y4^54R6cuiw6On@y zP8`EA;r>(q|F^>b&yhEjKus;YB||7*=hEWR=oK?DHC-E^)|x|`P(XIZ!hb6_H`j}? zNYhP5QFP9mthQbG`g@u>sRiM!VU2r~38Q>P7wu!8z{Y>E0I51xbr(0OAZ4Mo4~M~O ze;w_gJmx>%54RHOl)O_aE1IxHn!EVMT7Fb(A2_QubRo&qh_r``Hsrk~o7=%MvMs_aG!e&E2gbLiO^4=_(YTUWZ(?-F!SZ zWxZTLbuH+#hr4Rn+-0KVn4bzxAq8tj-g|;X;HGq}=I|4QzCz4^>QZq7?q3}f6CE95 z(7mv6EQRtnvw?djH=iPSTlGhSXBU^7V&yq1VT#ReF@}!HZMt3{LV$d6=v}s&bZdfs zm8A|}P%cT61OeZB3PgM%;UGY@D)Q~#(awl;AF3;J8E@BwWz@#nzKmZ_L*r{rX>(e> zDGroVGc0)-HllU}<}Xw~dVCrzKFB6KYMlBq&xGG^TO0=4paH1Ho`Y*DXgp$KY~tf< zcVg39%8lHO$BoLK7RYvUv3gn@4k|0*g7 z*&ZT^-s-m=N|C+SB(+YxtL0^~e_6?RU>i56x+)@PMO64C>T@3W9aB>3n_=VS(CSmN zO4}u2F?AAQBkO6~&iop83%<;BZ|-QqUMqD#cJ@2b-`lXUZkH#xDz2AuD5@9>W(V>m z??nCgB0T32J|na(?FwjOfVw`ql)5(+eZlgto7e7%!AnjJ@T^#v#c_8SbfP)&y+)FNEwdD@+#jrK}az5$L3Hkw? z9NmIDSF3Es8bY?Z`_qXmY;8WK!x1lY+$^aW191tR)iVP~UwemsiBap&ARKUo^^@po zbd;y|0xeDxh_H(VrdFb%qqKDR{7%u@04A8>FV}}YB7-=VE_nq)R0LR`PCIIE`Leo} zvUfhu4>A-FuHX)g5+H?>G&oO&E;8&yMF*&>!L`%!3OyHUey)0Sq9x>jb3C z2X#u()HQKrP!9Mg0D4T!Up5eK^mkBR*uY0v0&I!Q)%D)LlHHaOnvk`82Wl|@n^i#mMF)IEbaLFpBsrj*~D(xOwwF$%u$4g<0;?+7qpt!KvD;(6~IRY0c zAd4JT_(63uzdKTkCbWCkJN2q*w!P0T#S(~MUod0EbMwa3{#FfJ zyWwoBO2mO7f!LM`Dx6(zEYJ4L*CG@&4u#XEjM}YjVvUy_}=X40B)}yC4X|H zwb{WXKYx<*nQqpV>L{i2cx)G3zEZ%?zN809!+|*a|8FDw-*d^1YRHE$HblzdC%PzL z!|4h%ggJwRx}DW6-U?*-6Hg`|BYm~ED3)%vyy{qAS8n;QO$<94%DguYQqILCUbZ^z z0}Q;FpHEkKUoPrz(#xoS#2v{Frk@#fv|WiwVV5Z;pEtX{Dq^-z?R6k7h%|f?B$?p4 z7(!9$kRx@+3#o|tRqqyrSfvdih0(87v@0QEZ^xYI=CJucyIIZrhA7)F56=Nmtxmf z%R3>yGX2P!UTlOY1Z>nPN?I8UikU7JcDg|?#C`g`16ne{={obZT;DdAi#_g8WBsr9 zg(K8#TH2k4dN041|D-Hvb=Gt|&Y{gQt&3c}#_aSQ43B?^kdF}7#)o{odDS{6=XWh5 z+?owbDD5tXlKRYUSH^9rZog`Z{;#v%wuBO`LlwWLpT)TmW=W!$!WDmqLR_|K=@B$| z93CAV9vOM((H8a4pC$im#mCA)ZnkQT;`-*gh-SQj%b7c}Ym20F7-I zO05lxBMym27exDx>|-_9g?>?mGOD$coeOD52&8(KQwjU04SF_QSg+gPwZg^IlthrA zu33)wyK-ixj5}wBr(EvTl1mLHZbg+NHF-(%wcS>@MF+y#B-E{+U`d&IDCcn#U3qZ2 z$4tmg!{<6E$fg;>tGq>QBO?3A?lw5(*ZE;MNb`DN(N&W46tfP?}- z&I0;I-p`91Z8>l-&@0IRhEY&Z@Ei~z9*E!}jF;o9;~*+;2q9R*nu|pfckiYZHa4WfF=?b;n-iJt4~&Xy*yVOsXJJXPt&D zVcQ9pCQEp$lvQ+93z3c@oO^EM9eLGeP(vJ*Bu01A9x9AQlTZ*?8SO`xq#-W$*ElP1 zkW#|O@EMlw5f)h{Q9jQW4)!L7o0)8erqktZ2P09-vtq;##3|p?GTjfYW=7Ct*Oz?C zO8HBYbM~XaoFlXB$kL3C^$#;5$w?B+0k)I@331k8H+s|&z+^FTHQ>$*ar|59yK!^S zd3ryy;reVgJh8#Hbh$z+NM>}E+Y|HVVL$jL^t4r(y?ncfveI}e%*f%zMk=y=A3KNG za9-(frC84Kp7XtU?#HWy!iHLD3BP$bNScBG;r|#(>5kYOi}WY{(kbg8>B&h!P>oWF`T(9{mw^o_=Dh{w+W&Z#V|2MgJ70`HJ?2|m9{D_epHF*L%urcd4_;9qgNx6+VB zSN=JQU8et@GCO!HqoOxh^K7=gLrGt}iR=OK+55W*ew*{dCngZ1gyN?(C z84nh|5rIKp;)vidK+UOEU0eclh%ps8psCorktLz=yPaY>J=R%>u{sr<{gOiiEuRE^3ryoqTbMVtj zCxDh|I9k97?^QYe4<_?}OVR&~Z=wE?($%cV;i(?L76lCfnAGT#_nHMFNY+DscZ?7dTGZ0!6eAqH^(W#nrv6?o>65e5gXLtJT3Uy zA}{)j3ac;JAk0M*K3g9D-Wj_EyBy|2o)M+besDKC)>Cd|M6}wGW~T3G4EEe0ac%83 z6YFIA7Iv_PB9{z?@np$Z-?rU99A*;MOz6_o^n9|I^W?L#H z_*eg%QVGEfmPq2yGtA}|7Be&IT%4S7&5iMPep(=m4J>9H%1x^+V=*cDaQU=98lHgr zRFfW(Iwc(P7m0-}e3&zNg|8Dad1EK!+RXkBaf9Z%63*Y{z)`7LY}_okiFJYEtClQ^ z-fmTc)XaV^1_~?5O_8*8v_*x5{ey!EKQB_TkdS!HCLr5^;}9N1KKP5CBUMUJ3IY#t z+j0YG63=^SPNoK_=?H@WgT+>K;U9m37e3_F|3v?JOn^4!soSGRZ5wA3;`%em4rTuL z{FMHTg>~t#h9vQHO0_8s=*qr^q=sFR^d*7E<(sOg9KF0vtdgJp@wBc`%uO_|NmO>k zRa;QOg_;7c#FVzgB|`0XNCv7^!pz9;HWwJ-)jJ)7?dgTkf}|kuWD-|mLPad6VOXfk zexyNqWfC?!X?ct887XPF|u|1xcyvoxU8ur9hTGFLxa6&ViIdF z{$7@sX0v{i35b==ck@n%@Hx~0hjemLtq{AzpI41%72!i8ldVp>+u2$WumP-I1=0u#T^RuxIzN$A4h3pmB(gaUhq z#VyyYH{-SEIpsc6d1u zG60o_j6<~=$>BwL$w1h36$Xb%ZiT?031@&L&nVwbp_Ograr|PTsB>o0*uJkxJ+K=* z2-4$W-`ZEf_c<>zbDLyo|6)Jv_GU-0V-o6fH!9hAy=QjqK;n9k0t~>4y$6h|ubncG zodHafCyC*c2yu_*>JQN@!-yZ9ycN0@$-HQetCcg`kopWM)!3snAf(1xrYS z%$Bg+^aZ(D@&RfWJp2m)R)jNS!Nh_5p})tF1<3V-FxYyLjkbZvK6Io_`D2Qq>PS^e zN4crYT5t(xT_H8>t|du zm3Us+I#H6&;_0482Xw#kr5R=Z;=q~`h<%GwF5hCdtFa4D$0mPPOinWNA8)X;b~D@z zkr@_I-wqN>*f}By_{Nzxu4`q~BuSI{x0l&f$$-=kzX;6(XZOAG zv)1xGy_&m4H+a{wBf5l!xrUjlosjaVJi_S=85WBTkw&%4T@k8W@ld1K@O5c&tnIvm zeD&+(U8mRGTH}Vtf!)UAeB)V@#rpHZ+w1v1`N4U|R?DPPmJrZ-AgsNJ&zVSdiX~LO zJJ$~y2>-aat%M;WrPe}S5C9G!7(qhDL_!mnz|{%__etNw&h8eA!#>3l8r?=atOG{2 zo!*n3Z%=NWcQ1lxEl=qiS#QxEZtE|BUT3e}7#(+ktq!jvogdOTMX0&BD5R(8pfG7E z(Yvb0%>KKc`2Utde0o@SWAKkuenMbzc@XL!uqs8M=G2LJZ zE`4r{jkL$s>XKwGpV)45Z7Ba7FrG~d)SQQvx(s>n#zYW!D@(^e2|^_Y30ACVqWL0X z%kHoGSfpw6J7R3O1#1Y$B8xgCJXJKS5v>iV z=r$rj!$oTXLHk?5BHH~v zsOQwMNKAnE=d8`*SZ#-fse_M{u<_tBi%SC}@XNsXvxf9}Vt%)zHjuOL?Qa{|ye|*o zcAXxOnMemMt!!Hfun?khR6T-af6O}tGST;YS7z3UA;l`63Og)UP<>Ow@I_h<(A$2)S0AgjwpDCka6Ki?^XXAu*n&bDW@d>J%ip7;EW(~$+GRl-5L zEp;HbU-RUi-IpL~P#6~!Ye4H)^31YExM0d$uo?s+gaF*lS=reKH=Zv(zEcC4xV*Z8 z1gJc`yh{z42??CG_f@|W63WM-2(mF6^ac4cumkdyOm@t^*k|`Ko&m3 zDg1cfX{u-Q{O)iMMFePn+jdkj{XQ*S0tNhw6rN!QSjq>oI@+9&2D_{nOVM;dC|i zv6HdC;%g>gdKNir?_6-gyP^E)J z?gOQyw~bc?AzhT^KWYJ>@qe&LPhMDVZf-zXeHiQeyz@_)1UT!+)VoyK!?_Bv3U7(8CE6WTsJFt zu~-Fvmbz`H;Dx>g?Y4G6nXa4>{e_u#uuW`ud)De_Z&8a&Kb*L0P4`sC5dP9b&3@w; z6^g|y5z1MEfzEhujmtDr#w4-LVLZftMRQ-B>R7Jdo5&e*MrtF8vf`+YfW%0f#rGtX zd~sFsVcgZCmK1e+_y=j#$EoD1Szh?`=TzjZLo*vG$K4+>R0T05S*+OjQr}N{1t4%b zavAmw8*3haN_9l_Tk>ocayO@E)=#}u*o-Nql8v+W}Z<+uGV_!WS16?HBFu)BQhu{dH8^>(&Jd2M_MqFm?|%jMLf_I+;w% zPKLN~Yss8sAN{&*H#vn3jGrm zN(&hk52{?S}4KQ@PML{l6*+pN41Dd>-m{588CA2rwqG%W5s{7((RuZ zG_q}$qhA4d=*lp0vB}xy&-_mC!asKv1ED1ePB5g!lG&EYoMd|q<3|C@+xx0d&M^jU zZXFsxSr_r8bE)yW`N{8tFJZGGL?xCtz);>|esC%ODDg_DUbD`sVC){HXwO+=$Xx{8 zo3O2E?W`Cy4{7BYcsCg?QWtGoQ|P z6YVxSEV$#d55z3DsDn$*;qinvY) z8jl2kPt)F^Z}s)xT`XRHY3!xR;6i%`9_yrmqA|L2=!ZT(VNtVumS>35AP}Cjh za1dd$PQbCejo7A~gF_pvJbT-2_{YuZX(NwgLw1IJcQZfGv2oHGO_vXSQ_2)D*({db zPf8uKCcN7FGY$7u zRyj%LG_la*SyQVrk0MctON%pg7YyP@#Up^{mlXJ7gLnL%hxDfrs2gmJ-PChbR%Q$F zxtF*AuyKVkl$sESbM1>!6Tp9ncwof&TRC6YLqNqjgbICW4nW&)xx2yytxt&pA?pL` zhP53*ODDvqdGi`|c|9BuKOBeTqwa8IK%C+8`JLe*_NQ(#8G;=G@+_b}exK9pa5Wz0 z4@wz@WhEsd-i@P6`$@bc9aN(<;l%GqFg%jstcAqz+6{6!^$noVfT^vIsMmYDdy44- z&W#nLN7#sgVt`!nGBHhV>%=e*V%E2igQ8!tPy^9Jnvc@6yn8PFUw^RM)!6^WlLPbq z8tnaKJtrOY&DUL(T=J(>mUgF!4pq!X1-mff(r>$|ngbe#%jx24Tq_&v$P*9z)ds@~ zt1eU-!ZGl!6q=n5h!W=$!w5V31uH%Vox81xV=&x82=42kmubxFy_6F5uw_+aYzoYc5EADcq9o6)5*TjHb zH6@`9^{78kyT5+!zu50LLC)-5>&W{U;vbRXzb>)VT{-V*OBqV=Sh;j6eY}vF-1pjzZ%sqC}E8qCL1*PU)|W8+!wLKVIX*t)cWTX#fg0WX#yNf)=8AY25+^WOx8 z=yXUD+1$i4OuSG~NefOt9s__;zK*3(Bpqm72~_2dYe9*5)qJCPv9wh9w&`LT<#mjP zO`%U-R5D^bD=SO%~fmbcTH9?nWq+} z-3ly2*FVnhch(%%;*DL83fz6~PUx9aZ%8RSK@>>X-4h(ebyV3>Nn+5xx5xXqpZtNG zVk9Pv8|R;1fEG!646g;|`2j}Ukw#pQjH28N78Vjd7pI$CSv6oJD_~X`x1q+QL?%>d z-R{@feeLF)<7b$K9ekvoAI~bM^1QGR3H$4-c`^GKO{*eUiIogK)KD0@P0qU2q8b;E zf+byDTGq>4U zj{*4+ya1Y&6OU$n;d`H_f>||`LM&i=perC|_SzNxwZ=?MpmU-!feRot_@OU4of@9{ z5RfH!M-7E8p=PGrjkLJbUe02!MZdE&E2lh}qhBvebKSqS>no)M_08wqIY__L$S7>o zm3>n~Nb+F|5O8)mn8X__edp<7q!KCAETDM_ovCtN>JSf&?YD(2n9i+N2om{uvji{AsU@mj^vVd`*!+WUMon;uYoV^*4&oS5Jn=SeHI zc*UFMV%NAhN=^=}-Q5Eo6{}R-GbSrbv$IPdoyf=2OV#qrUjG#Ru&^`RIOgn(Nme6g zpR2kHZ0Kzuh_aICmzvyDnwp$6x&+iy7pqWoZH58xahQY$@{_!r+^Ji0 z98lk2X>&@~aoT+obxTK^z3=dl(Z%!o02lP4r^j22Lss-9E-*iU`H8|n>?k7&PWEMV zuVfv${=t{F_Zb=j0qww(TF|Y$WmNlb&?#Uv5hNV%@2VQKd#5-?x-baVnRa8An;chw z{Be(Wh0|btdLUf5-Z{EoxNy;(c%hJ@i2+!miXxhH>lA^{oTDKO9-g)fL4biD9C@6dE1)4#HN;p5lZZFNm69aHhQnWNj+feuWczt?Q>;Lp z;bt?lDDvcZWuQNQHkIVkTJgBNB91AX`Az_0HeVhYr2VoH0@N20AGi7tP94gr{I_ON zInBhmP%!($PILuYYJZotqe6nzj*Zy?n{rxaVAfwvX+s(wl(B0EJ`&-!Oeme1I>Qu?QCaaBIz9sFO z&D5er(4zKDFCe3N(woYz)nIa`=P8v)E{H5W}~b``;lz$)6~fM z?58Qstmenr4`pq&@S+zN<&V7|*Z^JT8V+q=%AY{9Z1_WV z)K`P(9{(j&6p?aUWw}1BeRPo=FtQp;Q*YDFz5LyK_Z(?vd+#W5C=NJMnDx#E{*!ACgZ># z_~U{7EV2+GL(exaeu? z*XH&Gb=_fum>9v9Ik%&dwZsP-m#t*&#K0H*-Qoh;%~@a1^ercTJqzc2JL)i(K~_?~ zF}c9JFmd)ZG(%WcC|Q}AA+h?a6}xh9Y|SxTgeIk=mQF3_imXAZ0EIHP(Ee)QM~?bz zvI~07PbWHE$hpzvpHoxQajZ|MUwhoPMD=Nw3SD<3ntWH}>FenqXHe3Vvext{?%^|J z5AaX<%EVyN%i%|}5?$tmx^t!-cor|hL+iRfQoRnbRSWdKwmj=izo)(X)+_4bvN|I6 zzJc_F*LUXSyet@rPAnTfn0ETBt}}v&bdbXGz}NMo`E~BP<9sivRDl)t`faoV8(KnO!vcIl19KyY0I03v*ez=|Oa;6GH3zAPVs?t>~AFxzVNlp%m1t2u}IXRy(A-gii zWYn9!tcNE$iD~s?a-t=H(#+R(wl829MySJ!bK7~UQH_M7#~o&epFBr#r94DLpM?m< znxXR4L4L3@^6>C8G6KI$Sh!>DBGyl2$!B@E9YTUGB)b3&m%$;9L=_D*73H=x1EskO zW?WwbTm8l(fBdMfb}AdzGSG58x4h^8bh9b)9ZcE^50?yjAa;gNk2seHOKrAGk1?-= zXNgsvc-U(Y)`ef}mw6t{5l>znEurkIIfx_yyWAZ2e4;TnasU7nJhj;2Qj_hIG_^Bm z0Wmz93WPSS-j>WGot-xBRlpheeQ*a#Qa7*t6AeQu69`iC=l z-NBAktCyR~m!`GGC>7zRBNw~#fO$me(8}UdP%gQKG})wWp^NUXn9>YIe({|zH1O;c zprTwft+1lED-tmR`D+2{AyyLDn1`k8>0mcwYf!;=(RP4HB&&FiL7paPs%-4R#n=n1 z$={1!Qs3ZY|r%}B%s8d2G1*$uLi_TvT_sG{Ja?$(!o7}s3L zGE+w}c?JCQ0Gzx(DHLbWJ+yMW{$g3NyIiN5%5<$a7ua@KPhQb|F3xP}mE)IQV<|ny zB*cqsQtxn?FCbn>YNR~f3D-bg7rJRaho~S6Ie|K{OyIj39DeKVi2^SdI?^QM7!0CS z<_1X=c!UOSoG>vmZftA-q%!Lt(-rj6(NtVc+gV~g2*_Cb!z8Z^vs95WC^+ntePvJ( zX1+#BK4{>-eJ%dv|A2zAEdr_}M8NsD+zy9<@yfD2%O3$*3|g@E)uk$guA0J>A zM^Xt{ZI28ALJr0zUNYZ|0oheicx4Qaus=A*N}7-R@-ch>KO{fja(}u4U?TC)%s4wc zzjobsdjfT~?#PB}tI)J|DyeAHr`qibt^Qo#zpN9}<%XfWw4$lSP zKa5gple6wk+D?}PVu}FeNPD4tCSzR9+>7bGwR#eg)^WXCUw%aSAvh-z{eg)pdPp{dN_%O zU@@Cx`81CnG`14--sbs{Y!2VDP*7&5AIZxew`aXE0|>#M8QjWeHGJR8vI!=cjdllxAn zGp5h)eOpWl_e)6rNrYL&vp38L|?s0Nq%Z5LdN)|R0$hEQzC3R8BqoU+gT}CoT^LS z=Tx5bL&12ny<6pl&yV4&sp70&fqgJ!=L7#De7@j94qDGtxPK+y5yMI#gS;7@n)a9- zN{IHla~H(q7o=vV^{0Y{(TL{5OTsZZGXV>m`6cNPIn=IFS!PL%x3Pc?|1V>y2ueakcQc&7KB0{y7g032zXljSIdA-igHG^4ns#4C-cENatgRqw2?xKL&_JyX#Xc-~ z&`IT3afmueQW{5-pJvL3M^a`*KP1jT+DochM#c&bx6@DnjZwe0p)lE>9yRF&<@82U z1q(jptI*T-MUoaAB)#Kh;O1V~I(sXVgln_~`U8M8l>aq@-%oU%I)3V5qB`~)8H0tL zjoe>e^X*$M%aJUfhttZ+N?`n~%*FkiTbbtf7&l2i$HuCUw$_|c>BqMJ-7Sx~XN^WY;%#cv_|4_%@kj=9& zH8Z!cFnL5k!6FlGcG^$2XGy{&O-oNFB_@wp$n$YD{?n0o0YWhU0~z{npYY!pQ%^$L zP9GOx|JGcwB@vGMgqs5=7A(E^XGj)n;Cp=%6_I2k-b$f!c1Kn0B>k*k)~WI$vy8>% zixv4>*o7j@Pl z@#E#k>l}Ip<*D|i0qo(hA4dld|MUX9MA-Qr>}~-3F+dR1*LPoDl>8jia!eb!MTv(> zi79wKp3{Yhgh%SS?j6KBi{j!ZbOUpPu&ZE;J4jNFbPg-d;G$&BU;lVfxQbVPvL&2{ zG5b3atya*Xc_#`}nk+CwHs`Dl`zM7EgZE^2zy}c(!J_7;AoGPKqpv_Lv^WQ1ysS6@b$n>n_>mPnth=B(nRYfO%#d1CbFWvU!dhF$p zt~!Cvhfk*YqPVJ^eQ^xdETC*Mx!oLkpW4{IAVj+)!}qA@IGyn9r(k1ANHF3O!MIUH z0u-rxf&W#0wf)#S>x`V-<9Lob@9(4FxcWd7CbV1zug>rJPGJH^z_4%al|ZFHu=e z`mvh z+3$4+Lu2=wZXgj+48bswyU7JeJPH|l!e(+e>V~`P1!GJ~0D^aSd|=G)y;?#Lmf7HW zft+G0m-p7kZh=v*t)P%n>%_i{>3lE|E$-K zwEzE(HjS}xMdT(a$|3$XrdeenUm#q-^44tJBJtga*gig?<0e;X-9}2Cv#?2^2Y95t zsZOB4K4f3me@nk%z;$=>NjFIm0PL^tr!%pfwZ1}AH96cjl6m?-iI;K*Ad8{In68(U zHg!u7JYll4*73qF)wvOerN9rd&6#zE8)sz){*3&iDDD~l&DeMX9?JgsTo7vqp&J4Q zLlnb6(`9!KTG@_hMT^=Z@rGyFg_K|j$Wp>ii~=SDV@-y~Gx)yyP^PYVAUw`tr9GKj zxu5H1TRa?}K@cBLl$1gdkF$5-CgJjS{=9;p@gZJhTJPa>Hjw#(kJ?T#;*2E>J0JGK zznZ9{c=H9&Kh?cTpRK;J3*m#9s9y4@-D8w!jZ%&rJm50VOqZ2bZi=|^ay@CDaOYlAhT)WR;5ZfaT)8mmRHJH5(Iyy2 zK`dMuGmr#=5%eN({puAMuXF2yiK*MnJoj?wSO3t@!J{c6_nDiA$}@sf$L#g>t&Pns zvCZ|XA%9g^5;?M#(ZFyh-sNb9JO6%sncx|HYCSsi*}B-~X&4E?$l9jSd|GjH+M6r` zMRQ=Bt4D*vMbDQyswBJ3ud`!hx^8aPj^%qk2>coJ;0ec}pTkF5u%$BPf&1qD{o?cW zgxq8jYA`JxMsqcssT?*JC_u7ayR%Ze&e+&kHBRkzV^fc}-py2J07zW>qDXl(jpnCS zO!}gsgrAV#YK-+)Bm8P55qyj>YQ49E(gJre1C>Kz=W9GIE-q471V+O!8dAxw5P;Q#Y0=+&5&B z^KKOaPF6QlO+{U0aWmq4p+u+@J(31;8+<_F6!m~?`Kwe9sd-j+u<{qvU`SV5prz`iMDSAOF^l|`6 zfLhk`guQVLS3ro5j#jm*Ra)fX+c(hIo5d(&d7P5dgiZBJRu%Uq@DhcZYRR#_)l}y8 zd^F`IrQ#W?K078UWI-P1E0{nvxQQ)~9?tg_suLYWVK}~YfOq|g7)naKwc*Nwx8vWC5Z<32Dtiyrt zIB=DQiAn>)fg|$jEVtV&IB1vpK%rK!)cxaECxIqkc>#liUP|9`%H;XwKPfz=8 zF*p<0S^|Tmr_Z!;w7HG6cLYWV^kjAPM6Rxi^+*OH6Jt^cS{mAqwtp3C2eDW`BU_H! zFK!Cz!crZiAqgeGG#xfRz^24S#-QqEd9gc8(w+GAD;zBbnQ}LYw)!g^d=f#o5m<+0 z66T9Z60UkMCkiG@|L~Bkg2JcplK}bF4!ji`WSN1ZHvvI^jYt6nk~bv(R~j@DL0ax{ zGXHZNP%!xHe@KBbaE;`eNW9MAg6Fi_pSDHhUFaPk0vO}Wk@;{_MR86mV-K)kNPT|s zS2hhFZpxja!!e2dK;TrCr^Gta*-#Fa7H0tnJ={(>Q{y4I?q^sD^^OD=cm@pRO3igJ zvcI#`J9l4fmPVMH8c32}2sMq(-w zZ9L9mKoIj>TDg5b7k%wzJik5PS&VqzX-YCkZZm5jKg~~pJ1>(|OEnlhfe<8~|CREy z`hi>oj#UUQuNu$ald6a>aLw>Col);Z;bkb0^T2Xw({A2y*3E;60m>jW#AhT4qQK`F z#X{T{g{@%EM*1DZ_$^)D1-=WUoFUGxe@#~p%0cd|#8Yocz>xC=Th;O+eJl>X5XGtL zS)akHX57b+)wWg+DXdjc#LtIi%lF;05PlLMALYgX(*|d81R1`|Y7bgUWS7_8{TM?v z1m8?0qfvKEu14Oj0@k+2>iEP?oMPhCM^pgE_ z%WWJ70C{nV{y7aG_#7Fx@4g%3sp7#nheJNc10pr;-Q#>}zpkHz)!@@axf2Tm4alk&9Q>=>xcHJh)EzC$60Ns6)+SZ+rwg1!aG zg2l+RP1WR&(?2p}Q5C-2O@dTHepZf?A)%qt(Y`_~_}Y|1Jawb2nKV)hBQTAn!SdIM zF9Ik1V%@|p|AtlYElZvLpAPY=xoC^SinG7}oGZ995h`aC55|*EWtW97N0M)TC7L9c z6kvO+dhSNc14pH0Y~!}WU=gRxp<%OXc8u}4)PkxogtER6s#3o!i8;celr zn}Wi`hzt2o9+GL+&i>=?e9yzrzqhwHUuA7|_3LcZ=)YNDOE6qf-J9_rV;m#Qq%6=&gY$WcE}w0+#}tw4awz!XRz8Nw;zLN`1jgczyX)Y`fPq`q@n#o^4*3XAS6N3 zwGTmBmd>->1f;PzaHRa51=~JbhQt>dIfZZ45(JsPCjYRfySU)ub&$B$6>6bC8K0U? zWisa`!S*pc;L%EgXrG<`p&d1@Iybo>r|Kp3=Nc2=htwmg*G|nk%Zco=&TF*SC#6^& z5A`CQ8IXx)qchw#K67>1U+v9^65{lUCIvD6Vz~L-=p4f7T{79{(% z4D${sSjJ@<4J%2Ib5K`HtqIuw1#`B9!TA)3%4HY?vEwBM{YIuDAw*o-56^2q4PxD$ zV7^~5_9nVzEHEipn2&1$%X6r!(}~G-gdJRM`6$5Ob@U6=xqINBUCv~8_m>FW3cLC$v;UVA|5 z%3rw0Pbc#4GP{~x#Ai@&slM2L4`+D?Zpa8 zfI3vBq%H^G-{BB%2N`rh>LTGpU&x-Mk?V(mQ?52MxyBP!U40U zb-top*vkTbvW~v!Tm5IvyrLO2G(vD8gfOh?Jp3Iu0kqdBd7=Kv>q{!y%m4HODuSi0 zw5c!#l^xsPu*VFqMfpvwMZwFvs*Fh?W;EoPztB6Gm?L1&2azHg>8!&E{tkQ==EyjE zz6X!M`(zWi;sz& z{6zuabeJ*rtizUn2;p9lQu?WZP>t_S*7&t{3{DC43Ehi0bT)~A5i~areDq+n%2g4e z5{{QY?{;-y%#dTr!5%70SjBvi!@1aY7V za2RUf$YrS5rNN45?$!J{DI21OS8^26DTBd$JC5WiAGv=2>)HOb=|8QFTLJ`g&c9w+ z?&JjQe$i|8vGW~1cje!g!3i5d^D2l_2QwAx=Wsktc6JV-Q-=7n zJCSFGfu-b^yW3Ngq0Fniuh7RSaU514BzQA+(abudt>wxGJMFVkeRb59ETkFyt*_{5 zClOF=Y7teB1c{obgGl1q{Iy|>LBZl#II+h!-OHWb@7Hb_-HHiqDcvmY!h1JqhDBj8 zQ*QN5eK}wGW5bIb%BBye4sj@(-@E&??@xRSkVSj-qXM3BB`78f)zu2LM~p;A-$|cYXD=M%48s#6qAY;!BrGn!4p)s_h z+RxcsqAV@hpOz@}RsLQR<`ib;drpKUQ|&1786r)gO`u+@=fFg@@H_&sIL=y^?jqXs z={hd5V9hZm)5^zv!vElIPdA`euC~fl`u$vOu@I$~>sI)f!ew{mKAd|vQ7BEg`46c9 ztRNRu9=UEzXEYrHNdVq65=tsc^%k5PgCvIbLYqA@E&&P8 zSv0?pM)ebehPIZRF0PdZshu=4)B;Q!gj0?jP}yUP5+GAMl$Glps??_MUY^Hk*E7AJ zjU=0h(?64g2`j|;M03uFw~khF)C>j>gRtUs3OKbC_xXHGFBb!AhNh@ywH8RqQ7MCI zs!c?fB|fe->Bg9vF-Nl~$|{`~jyK>?kW|dUjXHl?WE5AlM0=0>U76kW^&8QkOf^aC z-(%`$$`LMCOlp5$@c$`q|1?=gQb{-*|K!gvY>;79BKE5i6|3MgR)>VmflW9vi>s`OnLfNOU_b?j&^1y5N^TNyK z3fG(2Li#>MCCVgj8otF3xI{fPP(;694fO({<0H?kBrMEgOQ24Z&wtMWMbrPdf`3nC zB#D&c12=FC9zp(qC|$liU;E;CyNfduX@ad#GPBhFNfOvPVD=TI%zF<`QtR^@xJbzuaD1`LB`UQAT>;n?hSJMc%o6^*fnJ=N42$pT2K|Hk|OzuV!(j{L^rPYwFBJ@L8jyoR)r zl9H=a&VNyZNnCyy3NOP7 zGqJXHA07MsV&RKnp>PC1gCc++h_x6<){W7*uI@z!@u!TvHD{Kqi$iamop}7Q_;!?>|PYF3eiK`oAjhzh`n72T^u>QHlGrImDDN3%E%u1-yT_#Vx7+pPK2DG~YlD?`guRzgtq^(^vT z@P?rmjF!VT(T@GfQSG&XWyNHR2rT@o?;36=6T#9&+m{JHBOBP93 z2C?SOdw)S5)XM;TQ9UtA8V$ohto$#7DzCpJ@Xp|n@lK4_H8(#lmwMN5+Gm`iHUL|d zr$0{VUw$6Odc562S%MTDSNfxhwdu8;jC;PeO1&3&0o`%M!{OS1R%0P(R1?()`}=?@ z*W@}IuapmlKHps(6e-AjN`cTmMPyRC>|HR{>Al`ZpCVAANdNqW%=2E|?xHCao1!$7 z8#_w?iW}sMK?`Orpks761+fldv%bO5{wi)~u~IFDkY=@o7IN6=*q8k#;$JuUuQT~4 z(0FIURYAMjXSj|p=WL}9;xaQcV`Fh^21x$(LjOSv>N{2ZPzPS~$3Xzp5+u`|YLoM5 zXv09zm6(d0LMs`auCH0D)1`nWm_`6O>c_|BhUWzZjLP1{_GcMidi|QMl}|O+9Ha>H z7`olO68qm=Do}oQ@0l)+JfB|UQMfAt@R9cv@H4L7oyYLOL1Yk9$MkB-s`f12-fr2S zOol^&6H&UZ~dq1PW=Z_fQdVtO)7$4_LAj(cQUB{?5Pu zkD=bk6)6W>vnk2;Hu>pK>4TRk&kc#J{t7a;kU#d)_p)3k!zc@zD+{k%4kj=6&Ip}a zyRJ5fHGi(;y>ncCQ=y&~6T=99l+Dn$M8k`M&3I;A_85}smqyC^(Bi36l)NRTR0*j{ z+kU_9{WqBjmj$zl$m^HV56~Mkk-Qt+Of*#ZXriEy zvr&V*&P-?uaGKqgMJ4PxztXVTDEp{dr`2UY)Akiu#-0{LiydK33UCum-52sASY}6j zg+`b!n%~Jhux7+ttTxAF=72a`j#eMqj(OOxcf=N|6Z-#cEu9fx`~fDK(>dl1KGU1} z{O>kQ7XM@|{-vCTzc1gd4Y>Wu-ia|0WH1quu}*toFM{HK8pYFWqQ-^48&vYFb~dnW!p^#5DXoA$gsljIp4Be;FGYX{Wf-wSzR>UelYsskr9?fEBlDR5vK(M1B@nMYjV18^`;M&?xcFL;;h@wN;9mO241u_Y z4{;PHN+2Hn_yyLc30*txuBD^`?eF(sY?+1??6_Nju*YwkYdqgSDBZ;#U4lCm#_-Z4 zL|h8HPVY$zhfaQlUt3K5oNKQsl;1 z|KKNg-yY`gu0ah3t@bO!hthaOZ1kO&zwrB_GVC|b`Kv^{$J!RbIu1Ix0qpLVDT;JI z9!`_ePz5i)i#V;4j=nldt>XvA*8AUaOTNuIV$k$F9+wgO--oYBud(iZ!1O*$vhA(` zwyFrNjo76YmA-aCZ$#J@UW*?)dgxd<;Qr>pj)7vBn$8KKaI(7|ewcchZ8aiK+P={% zIX+$MEI+Q%j*_IiNvSwAUND|n^!$rZh;}#=!l5ho~6^8kJ16#N{pHM$#ZG~h8 zT;5F{pi*X6bDI(~hv*{{r}>+ewFbyf;@a(0xZ8>ES!Pf=2QBQ(-E7()QJn8mq=)WW z#`W6#{q+*_AecKCA-fddmDcbNr}0-Qw1l$xUUyM{D|GnDGerTh%Q_of&hpX$z{G6n zuML7q3~qlXx$UZbe_Ur4X#jt>`1YUn=q(k3=8|rlPAqWmMz%;r6-jSSFNfC8IkAW# zI>Y>)>n)^^1SJ0VkQ!`JnP5k|uhS$-ic(&R-;|KQ@Y13%sQ&2%Kx_51EjJ#$^0xz~DG~>BgWF~8=mGIin&7bT z3NF<(uHS3$kIS3bO3h6NZq5q$o2>o9`ddqlWrtnf&d+~77FQ=njwfrsL|$vv#+^i> zL7uV?;>kvnCWcoHLf4Zi2UF6}W6%a9-I(+OSV!JOj}Sin!uwyuGQC zsOje#8YmgQhdQQjs2`nEKXTZs^7sC3my98)_9hgkslR;5zP+^Aq3wZ-a4P_voRn&% z%q^`YB2}Arw`-uk#4DVZmx*|BKl6~zPNa*}go|5VHqvtt&Ptb65Z^@Y#lZ=B?`Hkp z^(tRLkY~1xv9OI(9|HrTrDM39 zV?a_i!j5IAKeS^Z;fbzN>3odikV(WTuZ8b`6%rM~7?l|_rPp(NqNx%SahNYDAQx5} z9a?-8i?wAoPour*8W(JfCHhS#-4$yJDeo(K9^zwb>w?Sxt{#glL)f@jqam_UKB^(p zJjP2WI2lpK%qiCGBo1Rs54~pR{M+-rJ?s*nqwT}OkeKLKtxW*dvq>78LXdJVHYda! zV>^jruSy1n1C41{1*?N5(*r}Hy9QG#aMg!#vEKEE*YATBN~u)Tuis0&=l-VO)m~KA zdb>vbbUi9f{jT0_1>gfsY^-~i>13sB^K)m7X*rJyOGdpx;L?qG{%Eq*5gXfb>6j^% z_|=i`zN5kBquG{l!&fBUr}uvLaceq&byUT>3CCb^$D4>G-vlzos7F?*X><81MB?P>l@MBW8heCX*@ z=QMRzMk%PhKwvW3-!YKaQ*cs2#qa|O%^o*kt(@qfycK7(`F|qma`@Mp{QJ|XnXJVoY`AYTNS^qZVd>n(GuuP)Mz=?p}X(a_M6O0;)+ z9ewZm0y3ZA-*X7DtuqkaZ#}$q9kTV^+tckb(2ug7X@0#)-#1S3}<|HjD*UC0i zCf#`He|Pa=s`0zg`B{5=SDXHMGusE@t=T=|r;QWmqvn_on5dLQ7GyDk)!^_q-wO#J zCEjane_&W?Hr){^*R7U7ys`=3_PObHpoDA6_tsummtp=Ku@I5aX2K_DU~IVJyV%C# zwbh}~d#QVMdFSz3%>F2kaad^V(PX4Q=JbH2nY=>d@Mgs~>oqU;$m1c()~}@!cpGZ> zZLy$W;}I>=cHS-~dq3~f5nqu{x+zuq`~K0F1M{i2_5x5bmIY!@CD*lS4mo<5b9)#%dsw9qgK1mi!FZD`ZVD&3^T2x!6Alg8J#gLyEx6 z`-aEBRJqe-Yc1+LRl%d$wN5F1= zxSS(4mD>`|-*H#OShjB?XUxm%m!hcw07uQdg{}IJJ-`x5l5uZ+RaYo3yyd|gS!`E)eKRaAv2e}#M)8n#;S^3sA{(>A zsrnw@$j1flfq?-+rWBQvae$vW_?bfp=}_}8{1HjZ77bi~+{M4nM|)_F66sD z56@BJF!s&t)~7xU#S?7;j1aC#@UXI&Y>j}DaLg6jID3^SvUC+ zC40ZZ?e0aFu*e6|ney&)CpMn&Q-|g*l<#jcllg2crL5wVwxOmv_bzY_pFT}X8Rkw3 zD1F-6xNag8`+-S40%mjyn51fkpxp@gD1*52$~f(S-T-r^m&52+$)t7H&8wOscdR|J zMB@&GHah`2*S&2G6fBz9vZ8)Enk$gSyC_(luV?ES<5vG50xlNrMD%Z}=s6SLPZ`Aa z6p_CVFd+P%61_Dq?9Jwl?EUT=;~S*W23w5gryeekU-PF$zwIC@OeU2}Xlzra1=eQH zK!=-zPs%(S@|v6ve;U~Hk+uwT__eGz(|6C<;udx3-_|u`V=iBYrrs?E|6sGR?+@vu zg4=HlecIwHjk;oAmAIBrUpYQ5qmJbGVwGtDDzonj4P6XcZ$)x8Tba(6xgTzMcK(aO zn)d2r6&r43%)iCn3nXM<+2QzBFaTDCZ;Bpva zeR$5F^g}-zkxP*_NO7L%Qbi!>w!KxIme&3f2k7ffRjRF)0L^V~rgh&NgHNp<2O|Ii ze0!>t{I<;;GmTT&v%7u0I3fA=+j97=C{Ba>0I8EaOtHX(s+_hK`P)|eK<*q#Qi)w` zZNlrqT}@j7kH*o`WSEE}^pKbhdH%Mcqt3f^yC{?Gc6KYmrk+)Vdj<_Tkhd{A6g~u$ z@JT0BLnDSYcQq_E>tw*YT$UwpS+bld41`A9@~X50t=v3XKG+{vGV?;O{5bakSGB!T?JukLpMA%pV9)+3t`*Rz> z^&|o$0?UZIwb@x?Qzee&*2%NOju{p-+yJ$g0w9ksjVXzg*`)$0R^RDGWAmgu_*qnvn|@14&Ko-Pak z8cFl!mk%~-8}bIXc!jP@-h8KJxUq(5g=lH?UzCGI_6xmPjXMJ?RF!a0_I^i;-Ltef#34&G%=NGw&zBQay00-F!?Yg#0fLPVk$<6 ziTF71t2;>*M4637_ki>&lo^Dip(kfpB_5#n5ESuw(Hk; z$2}~VLplH3`<)?*^LFrn=x!^3A@62bAoZyCN9+zlaKgOqf^XN+jr>Ez@u2+EINE!W zl2Q)bKM*pE6FDo$&#Oiq^}P(Qy+_LMM=by9Mp2@-qF^hvgz&q{cX2T2bRtY<5rI>G zHwl;9Sa~-WH$_c)>&=THDoz$g?jH1}DK@KJgx4{zD9g}k9AnK45g43iGS7Dy@$D@WL5!eDsNK%4?bB5=cmeV4< zZ&z7SapW$-nn}bH2$jZMI(x)wBnksEMaC)Od*BR9UjsHm#_&(+qtw5|xeOosu$MSO z{*odhhh{384LuZ$hsFm;ttajjbt_h3PTTY!Y% z?(Pyuu;3C1?kg11ht6l7wKr?rm)kf$P+!KP~8pv{VcIh<{?8ot$r*ZM>JB-p60F zMCPBc#H4k+==AZFqk7<$1HW)%O+0Y&|5*C}%70>nm^|=W5YhR?g@JFHfCneTC=)~L zMfwR2UeGc)%F4e>5^kc+aIT9K@jxpsUsm}99wfN zO^aTz>B?IwQ1}SI19m~qk|N{p19i61^a@YrvCXUrQe7OLT7b>JWvy=#Ma9ja7 zx}_P&m8E^><}DwwpZg%9<~zW(u4Ub@#RmlLPO0mae!AVdI0l9qOw8O zl<1XxjmRSRHzyb*cMn8g?Yt%I7?}AaT{GC2=}i2NlZXrzGx!M`5w@crtEWjq-5S*+ zd9v{Fj*mmSu2ayT3!!Mq@%$KLcv*WyHK@CtaN|VePgi4*`R<)@@Tx@BNCkI8?#Njk z-%tgVSc!wi$3!0j)L!TMQsslwWbqr%EX0Rw$PAv-p4 z`m>q&I$Hh5qlgIBof1_dN2m9{Dp1c4u>`*6lC%GaA+Kbh7sV7^l{7?aNFA-MA!m8X@;6sy=*f z7KT`k(Z>L_g?BZ>G@ZJ(u-}N733_cfDhd#MlgGAhkZq+LfK7P%>lBE=)NXeDdk1-3 z_NH%Q1sM+t=YmQ0`w$JKX2aBGNJFscQdVXAa+~z7)D;=lT zeLN1MX7nW1XSmG`dwx30Vx`QlNTa*!T^2_^L0;N=n4# zW_aX2EDHrsYN6ZGr9N25Y%XK=V%l* zCu5F&U;45uCDdVz`Sy$>86q3&;{u)$c@Q)FnRFraa;N0j<%vlsqCew$zgIXG=EPIV z)Owr4HWhM$(8iwDzag514`cW~3Bx3Ou+S(6A7RQppbJklO3y`HFJdbrg3l^C$=248 z67urA@)B(4V(|q z=~dTTZ(zZtxo)trS?jn@U?jpiY=ghDKG+c?GOUnroOc^KevfmC(v%YrnC?X9rkX1` z6vce`;!Z>nLR7CA^V9EhOo=H{Qh<+Sz0QXU#?`hMKjSndoV;$pcJ1Vdzs60^3-JFP zEyyw^FJ`3KnWnMdOPkncE68gUrL9(-scyb{4prbg2)zXa`He(^BU^dnIIqV%U zCKH)Va6-bvz4R8X#J$jbr}c~UPUqne90c8!Zi*{R7*U>A)}PhDoeO&X&!p!2+i!c>aKK=pqxv~@Mo6!-HBHGFy zkW{DyrttF>7XI^5|AAORA=*o7QT50}9)wf=K4jg`pI3KxvK9eoY;BWc%vF#sg$~10cxc5AYsR!h%C{md6;=79eApbd2T{8^h7GO9P&pZLP&*5AAzIyhlgK zp^_3Ym-{l8&B5jo`GPiCVsF$wibQ|eiO`8>cq913ODmb@h*##}fN)PC7pu>wzpJJ~ z-RP5XFWg*YJ8L#pNAx70L;(m{X~+AnCu;PnPFNy?-IV1%*8ov-4Kk-lzi-G77pX*5jF~S*ug)T>r*FQGN*F%Ecv#wF!dJpMrz| zHBy>D5}@mMV)%?X-T!Ojn&>f;?$B)(Z2Cg^3)}>1$$>+K~b4OKG^&8hK00mU{ z1ZiyE0f;aBseAl=JS2SE0>)!z+`=y z#i>Oul|l1KdkwI<8?A0+dR@=xvYTz`!ijm$F2DL?OVd`hd$pDM{NjLQ<2mB}Hp4`Q zj;s&BrGP6z!L$2G{1b^2kGoi@CT?gjgk+H^yR{u`?lA0YG&~HQw-MVe=}qJ_j^g+^ zuCtGO!70-BN=vc1s+5!KqrrXBP)CUdlO$9?MZl>>hhYpkqs&>Wf&Vz-<;Y?c*!p2P z4w&5{g(d^+UKzs0;ut?Psx`fbM_xO3JYcL+egjLw0w9z@Qe<9}o}-0S-tILdJW$ka z85kgX0sCv=2i!d&AF9LM2Y~f*3IM36s}<`;33UJzP7cXSC4cUe0<+!5@Z+inxmh$J>Yp%E=*b$+dL!eM?S&y5D1@~@;z9ZxtzO%uZ2Pw72 z^#w0PB;7ahGca&^q>EQl!T*MPs8;dVZbPcNRN%K zGdeCJd&Cgag4kRMn5GuCLq+eKcy(PnzD3*4H;Qqj769%t53On7;;c#J@s+Y}$-C?P z+@8Ahs=m+M0{%SNR20l1()*Qy$`*){rxGf~8p|t|*0<=6{hStEYV`RskOn4+LVBWS zV1Vw02&+uzp?DRtUT5=(9{CBw8c9n}4+D91A1J{mK0|FDQY>_8a+|C}Zul~k6QMFl zf}b94_!|GIFH2#e1Ta(aqDF( z%W`#9t~RUwbAh8&#^%6_fj$LTetePJ;AS>zb!63bYEJuYDEsZxIKK`P_P~#uIHAwK zk$m4f;}l`886(TBqGhd5OXE_|Ptzjo&_poE7W0lj-h0qqlUSjgBOb*j&8#xC4wFgH zrmjMGoaw(m?X@*6m_Of5$5vm?YQC)s#((b`(8lkIM2r{o#&;Cef;=__ht10Kg#i2G zhZ~z&d-mTE8o4llk$6dG<~*JV-0q$a7U0(wXih-`uegs1J!RS_dRqlSv=cc(S0qIoFG7|UC(1%L2nG1^#e>vy= zwPpp*Cw@4++sSCz)3XtG{;)rayXMJX+gqu=z~5run%8L9!_X3#Bgx#;+9fO_v$qcB zKU-XRW&w_9%4_U|(3XJIS%74Ly}SteIX>6OlIf*l69r3?dxa~55u;FZa)YH=m zXytAc()pIUi0@qdT#KuLZDsx(SjwUwrJt6a7p&&9bMtDh4=MH_R!zZB6vsu=ELx@d zX38X^DDOCOBpK~!2G>f~JbhXxuU6U)-b?Y4q!WWD-~##99XV}5@0Ry=wAC~!B>S#y zqw|@x1C%Dq>*egcpfx6Su|(vjUJuX~w4`dUlNM)_?I2%igrX>RPmgxlx*Bp?yS2xD z>p>uB3NTBzd_ndAruV=n?E_9k#Y+jOvjFfl1Ms45b&)zG^r zbYF@wVLpkA{(fucTSP7j&q=W=;>Uqn4Ru6OT)CEU5AFoYDBU5LV^qr$8&% zgrAgsyhYN>6MH5e2U1rrt_Qa6?vJ>+Y(Y5G?r?M3qe_&_hA82;ahKwN1O5U+GpU8P zjGCR@cG;7Y1sCZb-g(!`vaQq5f}r=+MV8Iv{SFp&yaepbb_gFl&0TCwBhdLQxUqo5 zHBr-PJEXB%^tg7K5vZRxp!OPRjsHnQlgniHtOp;gLs<(ckH<~%CQr$({_TU} z7nOO>lj67gue5;V9+J*nnj&59B_%>km@BZe)Jnfig>PE|nv++-2%`c5knmH^OXjCn z{X==DL3IaqLi$wcC@V{bZBG%w0@Z#QVp<90$9}hZ1nT0F_;5AeV&}rL5%XH*xGmR| zZ2zxnYR+w2lMg3gy$5bc<9v(CCuW?=++=NsVGat6oTAfmP!zoEw{P$AUabRd`&C-U zrvHNlumZZB#KU|p)DwR+a#KCkrBwzro`-JK55Tlk6#Sg~Ew^f^JaKXrFo*?&kP>M$ zuNLvB6VlT zPEmh&ZyZgh7yA9uN}{*hl^ima}^3SG|FRnc%`p`!-d4YINDG z?XD7&rQVZ>(~+5w`~FlmYGBx?X&1CK85~uX*1YU-zk+ALH+>jUTrcb*!p>0JF=whb zu>YV}4BGXWN(2_>=GEPOy2DW8Pyl8c?WrNNpd`_GT=LOe#oSef-u|!k*P-40n*^%8 ze19r@RPIJKW_J6!Z{V&?c&)Oy@FM#bV2;(qf3?q5;86%H@=O|r9v)18vveU)DkZ(6 zL$Xk7ZMV!?*>y!TW#S>u?7RmkRZND`0vFJ&H38;nLbLv1a6i4yE2}2_VeTl1V)QIR z5J^E6a_A3PFe24+T^XC%UKWU@M26t1(Bwo$NL4Qc;41-#7Go z1L}67P~GmAV`HI42Hi#y2tH~I8$w7x!sNGK(0{CIx&N3LSK`L(Adc==S7_m5P{8&0 zkN$C-4%M%OJ3oMH!lV?OewP%->|`7?gWZIlA!uI|7!uFbU~`PxU`hlRyfzoLJH3kjavJ!X>}(6 zL@KK3YE?TDpGFvI-g=y;+w^5}|J80HbfahUX>ok0+u^$jD=LDWRZPLmEl=x|cwi z>NWJYLMaVNdIYG7fD_8x-2610{gok;i-WE*|5sWfN4UEJeqjRL$qC#I49MCmXS@~(;BK$GZ1~>)X&Z3ev9`*Gm;uJQ08U>=zG4lL@ zRDm=;kKbRMa2$dENnZjhy*IG>!cjlGJ%UID7X&wR*srMABD`}m@hO%z{rFp#Vgy}j zEPYbL@0Kh8#?hyPS2;~7f*^_ayLb?|`(^EUzLPf9nWV(S#&mdJL5jU zclvgGzv!lx_#;u8`_}BxFSznHG$%|I8yI2S;?Lx&aLR2qGhFbBb+a9nj6}b_eM=GR zXgXf%w6PJgyLG(?x8dDP))lwkZVaEJe(vd7963_9x?@d$!eIJ07 zZQ@EtUdc;jK(I>*>k7Huj8hUkZG%O`AU!n1-*7e{q@!bq@QF)`=KTv?O@&9&oa7?K zSBjg{rCN?3CC6lGvFW*%!uVP2>-r#+X9b^*OHykI<|fdzNg;t7?U;-%2eu?X>OdE@ zKnaJqz4YwV3!f^UTUI;GQmbBO57P3kMpT*=f;917-fTA21z%`BUdKk}k1%I(VH@vw z^t%Nk;vFvvpTZ5#34wGM^DP@MAk>SVyx@<9P*=Ixl*D?g^+P^B1 z6hMgT0`)c^Ryrf8-XB_z;I{2Tp~bn*#eEPm$jXV$UoKB>Q8+r`fC8Klwf-G zd|LtF?`giDr&0xAherU63EPcAd&-drUzB{zcLo}XhEMp}QF`51CCs2w$MC}yFgS|M z?R*oGJXt#5Kx=GoUx^+*nBBXtZP+mnPLyU-cvZl7G6Q$rj{kSIAcHI^LJhzh2e8y{ z2o6b!)~h>{R$V9`1q1cH3sYTG^;23HB_a?CdOVFQ-fUwjePdejpmcA>7jX+=GFSVmUQ6o&f*A&d*M|JrGHS zq>EEj+0;bL-jkRhjP0uSjahDomGt}u+zFeQY$ywHT7&;teHVf7IVnh9@M`i4$2Q-^ z!(x7U(%gOi4J!U)^~DuMd#$(5u79b{U3qUT`q&=JV?z@F3Ob$>0(DMn7WrjE07Qzt zbg8c`?VxNX5tfCFJJmvmwHZeTN?XeKjDr*%WCwF-ATE@vJ{eT}LDa)0{a*YTXhDB) z8D9<;e&xVzkzfR5qQRgRB->W?+>dNvpbMDvFk7zswt_$2xG?wD*CGc9Ij|^+j$A_h%S>fe0TF47SrNRyNmTd#wgS8 zdW5CXEOrJ4223;DBoFWliCGK-GG+&vz$Nk5*azcn<)cShx!;8SYBSFiu;)v6@*Ff- zx*Bve9h-6Vr(bg$96O*?_|vOX-oGw6L({B%>CTRomW_<>-Fbe$?{1w^ilREf>Ad_dwp4H(L)YCH%J#M5&c;E~gEN&M$j+I!vuj z)HEDxVKQ!FDA=BlG9D@t(ix1PW*ymerPd8NiJxy~Cr5E+LDkPs>Ai_o+?NCQR9e^5 zT(>f=rTT4TA7QBESO*8~*?t-^;|@{^5YfadCJ`cta7&9Oq%))U28Wfjh&KxU7|i+= z<2)+4oq?!&j0xpJB`Qo3(jiflV}5F%g@!MBIA3!xW&O$3csgF5&2x3GlnOi_A6*EP zCC(@HI)s;LOou^li0~M-krE(0etWK1tUg1H7b2Zo<*nzZ==8eKWX7TA3jIjPftxZmR}vYEdCL|YIg&} zJIm?l1e4Y4kwV&YjimIX1$P<`#){ha%GC9y?iW*Ab8x9VP+XIp!r@vQo< z0}vgvx&s~+Oh0rx(l@p-hGH^>9FRMs+M*W=GaZU8VJ!?bSeb{%CL+P!iXk8;;p@6W zgV*#ZyPqCXx(=@Z)D>OX6h-{p1>?&q5%G)#t3T8fEu z<|p#g+h@t1PZ6sPe^~7yYiekO6qi%ET*9GQbcG9=z({z!b2~;$$_Vmp1+3Na@xMaVnA=ltk=56ytZN^>zm!UnUY0-8UR zu@!14^jxKqveu$%eB&kiX-#-}d7pzgMJWdvi!D-?m%p~S9sHf27nU!f{rHg)OiuBM zsO}xAcxV6kxZrO^C;C1+94|vW~=v){I(-9ueKZ14` z&-E;hyL9|(qavcIqDR?ZChvmh-ES`^0Wm2+W8bFN6J>+5u%Y>q`M$+Q2GRbqhV%CC zHEL4q>CSBdj+88qUV+&Qz3&)d%mDA`-4vIfTmay@0yj_6pah%=@6Bw9o9ETzYkKy7 zbY_BOyeCn2$B%@(&H)Wv!L8p$Wn3sSeTikHo_m&a(N^t!KmuK_g3)FhFM<7<47P^} zNX<4Qd@0z5+or*%vqk7u_u*Ljj_Gma;Z;T|Alp5p2{|6elUi2}iTfbdI5ie0KMAC9TB-^0N+VgfM9Fw@Qr$y)yqckl(jm2SlF;%p#@>OjN>=0KH$AW!J zp5E~WznNYDQRk{nc6b|JZ{@taRDJ#CpDIxu&6d;~_k<`SS@qv`L$_Vn&8;C$Hkx-0 z5bwSN|JpAo_d2pTayaS}Uh%qF0@&%9#MaCF5VV_D&BhXzjA1&G)VZ!yXL&D%Ai>tt z<>{mjrrJk*aX&oM_d<95O7lCVCt#sV1iSk7hVZz6oz4F2oi*}OFAa^;v^P|K@_md! z0$g?F-dDwq{{-!XpcWo!Y=E9-KnCw2PLtQxu@Bcr2MOGXx|mglKy*x(kIaIeIDV&m zH?wUm+K|?10iS8~G3qgclH#R^Su~LEQOCl=(xVfoJF_ZP4d7hih#&MKh+SMIS%38h zlL#B?22}O_hB=Mn4iQ2rj~nv~V0EM$W%x>l)OKdX7sxBeQIat5e$=*+zGA<{zj)@D z&#k$PT*!Ny7SZ!yMe=#I_HW(GLSNWk;9SF*!kbU2b?LLsO+6=dSH{17_4$1i9;1k- zrvDlwz_RS2Y4ELk_A&K2|MnpBG#3eM0{+XsT*ZCIyW(mjXcA(=$tt|W|<-$#+7r40pb0-73 z!n8t*RO?ad2x`tP;qu~05HRqu#9zVkTiwAj`3)gR+PHtgCsnq2(ri7%h2p|Iq}!Q3 zyR#^FkZP}Swnjf~S4+DYURd-nJ5B^|KCckZukd2Fw+OLZcG=}(!EG3R6m7Tmq)xl) z9R*+jJr=4<3evA5DE|nVE=G!S?T&HLR-K03$dJcRXIqpDBWHSnv6!*;w7JZlgib3= z=U%b}%VPySyP{Vi)M}#Jqih5N%yYr+MK}lQzlS_JEWm2IUye!H-FOt0zo@%Q=TqGr zGVqJGXR1RI7pUT&nO@GBq|Zb}{0OnlbZ861$stk~Sa1rbzA*e}U2kKca)vX7$WVMN z&r+QAnxn~oz{Jde0gcr2X|T{Lb~UIRirD=f*xm~R+Qg5V*CZTQ0KmQbjQR93%2xm` zs-JsMith7HOy~!KPT|jv?7d&qn{<}#2S1@Nyza&&mz7fd5M+HPGSf&Bg_>&iOQDH& z=+3o)^?Sq5U*AB4U`@n3+j>obAyOg1;P-xjxSR!G;xSpSsiJKnD!8ZotB8W8&Tj3lMpbSL|l5v22({Me4)xUhblTs1SQ z`hxAH3gnxvcvb%4`C-Q%AflhQ(2q^cKq)69A__f>M&Fze5`LCPb8kOx#6%YBb+ID{ zdMxc?Uf-}i=%j?7LvJrJLq@s+-`??iXxtXVJvZ!DvO)AK}jA zQc#r9yuHwK%R<3CL4Z8!7)v-UL51 zjd$N(G9p-mHxzfAt51bM`^)?gQQKZEaM?Jtb&@sL;w9;Grj!tb(*c21*2X};Gw2Nl z|EbSie0`lZ6Pwa&ZM?zeLkm^E_jKDUq}%AYSEkd1PRbv`w+3*)K)C$nnZI5d)~Vd8 z4d`J(aohQo^QkE(iJ6cH!=Kyw^QBOf@*?b!HwAN(3w)>W^8AzyYb*8^2P)JG;Az#t z_o_Fv5Gs}PJ{l^VMW0Vvwd9&n#+*1`N7x1i2h&xJBBhzB;v!Y4R+`^GTElK;lVma~ zVih?7`}EOZ_X_l$_SLP&tN(h?xO%UL(xwfyc=N#&KraFm0OXzB9mF}3 zWcV%B`u)CI35h!1%*~uBP^%UmNnQ&g3`H;*Ae6|t9+~U`W+r`5KM$fmEiEJB<26v{ zjRy)F@{E%f7_Eqlv zuUn}Ki=)`tAC4$szYGjkB5VQ1iW)4U)ZLnixYUY%`OJS?|8DjDow$k|rq270EN7du zgq@v(J1geoB&zBUw@tZj#4q6{V-#ePh?l=p(2Kn8g7JHi_q~L;C@e0*HeLUFWZ^(# z;kfaHBtHMoaDnWX6xc8#()hDirf1pAm-kLYP3>=ezj9gepPYAvz7^m#kvdCFNpW{v z1V+1lpPk)Uef2nDb*^q)u0dhb8iuOe>)V!8h%iGmp22&3#Fw7q4Si!Jlq-Ji;wrP7 zzHu~BZF*=;+?WoXH*QC=v+RiaQ(oD>Gm4njGe_5AsU@z|FR6Zaclo*V@`pX_$omf_ z5J#=iF&z%~U^H#xH?tT(Lzr7HSDl%-tf&ljf%Y&1khi|3&O$FKtM-I8-F<4@u%V~Q z8FgN=#hI2xY^)5e`{?Kw=pS9}N|Ah(V-MJ{vyl!q=w7mkdmzP4DA5rzxe|PuU&<6HKpdUhP&e43Rs9yTgqtdn~$$v zAlNBOtKcmC#FN&Ld*=yy33~-@Zx}79;YRRP!FQv>#%9zhyo1Jnep_&z*&&tqSW8yE zQcLU3i==U|K3?t0A&=WqBu-kS~}QwR;A{T z^>2En45%SqizV!sU?uh`4!FSHxMZxvc)Xpz0o1;R$YD33kB9Xom)r{pg+x!03eW-w z^PzCh?T8I-d1snA7|0rdJ|b-}NE~^eLJi&~gnn(7{VkV@n&*dITkWQXSv&oC1KO@~ zu0v#$B(Csttf!>ywWuwER!G2S@|+4vI&EsiUQfp*Tm>RXoV=3K$yn^`AXLxE-m3#f zZq!1oK+%PS#i7|X<9wi=|{z#ihZeDel-9*A~>^i=d zUh5aYHo~!4{kvkF5ERq6eJus7-JyO( z*2 zWJc!uM&)jx9D=;cD#4x|mY*#HJB8$IZ1e@gSV3HKdOKUM|Q_7va?elVhnN zAC=Vb2_XZXE;83bKXhj~&Q%CMxRA53_fZ;B$l#Bxs=Y zGXTj2y;p(+X(prtHw{{}ou8c`yAgzJIXz@T-kx7QTnPET;rB#FN1v1_ zGfERbo=q$y+KRXygo?z*Bsm;i3DGBqP_$|xL0xMg%-CBH8u}9)9>;0_9yWh^} z{IU}nF$84}rW5XG%s%0Pas^}k3ATEAH%HPg5g+WD4ydy_efU`4Z_GxHHxUS{pwJfs z%4w_FifotR#i3U&0LVneg+-iFU!gL)_xVtN<+K$kEAS){WLgtY#!W@#?Ny&wP>}I= zutc+}nop&Hgr^hnPKSCU({(*m@A*8juc3B?Wup#;1QFXIzJTQn z`6YaZ2?Lh5R(5g>3U#85N8eoZqJB>V5t&3u7?^30Z^Oc8=kR8-q%1%iU^NTK%E{Sj zvY;eH_|EeYgd42p=(owQ%fGJ7WCGTsAm8vLXyXHXrr&h*ih8!D4IU}V;13|z1@_1D z0oY^4(T@YDn7!>4IqU=^IMq)Ttwvx>6u__r9rf4r*jx_>g=qhGI_+rwJkC|PA1#qM zc@IrZR2*+&J^*5uTAspEhDJncb5g*kyytzFR-ea-kf-}iI|5kYui(dNIa0Ya-lLaO zVV$Y5o?8tYcXLa7Ka5=RcO!5zHi8sE2t(h-u&pZEWH7>uYQ-v-5+29gv}7YsW$f2T zxq}NBLWQ2leu_%N*BDd4A&1ZEBU4}|BdK6Iwp9X#rk?}h?68R6@OVTqvG@f*t}j6H z>iM)i66S8#hjS!e^WLYc?Q9F)5)NDt=vZ3$Buox+ldG$8P)nlsH>3@V9imc=xm?9J^3%mp1Y>hw4A$oV_CM30|C$uA3|Rq{dKEBj_@6#USPH_!|gPt9V-1+gTlo{y@$`F8^Qi`1Ach| zR$g`(BQ0bb%qlM@$G0(3WA}gF4q6EJmc#p1AK6gZ(6nLD7CjDJ(-kt6bv0Q2w6&~X z>&0V`)xoMf9B`^NeZsoGS12t75}4exkTOwqB&L(&#b6_7r`ua&?-@=#KU zbqK35Kg3LnsgV0eva|0Dh~XLM*WgvkL{Y=FD}S%~UJ@O49v6l{P1EDyd{VA;_k8B! zJVnZb%Rl#njC;A#d7(f4B!m0w(yw?dK4blFlHbw- zb+<<;_X$+ADaBD7q}=oMFS~nQ3`93%6F-W0(b>sji`S+9g9Y%9c~%tMeO4IEjzi%X zuE^SJvr~(ju4P9IZ6*chr69ayr)+rQ4;wo>JB&959sf6^$Vb4;@{d|ln2XtG^X!#=1rUjJ)%*QL z1W7+UqrCt^dy^$paY<2hF-Jiz?zXqP%VSRL0WGTzqinui9Kg^xMP+>YTkNq$`z=`p zXfLcaekj;v8E4dypnk<4ui7AQ%$PiYfV$vW***Q0m8&Ex{)qXHG0?vTAMSp>;hzee z{wnkh^}b4VcwqrJpBn;D+O#hBs&{=&N(>fPQoTUVLrGE8W0B{q@2h}!>D6Fc0NS=b zOBg{bGTM=a;=5uaxfdjiydzSfjnPpxT_ayjLA6Gh8G$UolK^It z9hno?qVAsvH{xLJHwvTn)cKif{`TuOtnFsLG@RHJV}R?gcPBm;WoW2fwFlRT4pdUm z8wC~6cDci2Vc)=H=ZbRZoBZg4BX~4(Cu(qCkc_m{V!u1;q)ic=wKD`9%D~_Tm;SJp zk=ELWy)BOn1JBDti-!!Yzb_3lH$petzB_8iAnz^T{Nkd7GQ+!$q9>5iVC&BEY+b-# z(A|9}nJULEkeJf97k@&0w{Y-~AySR&k%`(bFj6{uaTq2br*?R^5C+(xwLZkxJWSj0 zxSwj|3jOdWkjJCKG|Lw|5gEodP;P^0iLkt<+k==?{3Fgk_|I1@<&5~D_y*bfKnp~VcKSrDaX;xlLi?&-?db*(u>#B^NY9rMXr*yn`QrW;dARzGU(X# zIG;pLcdlV`OGi$3`DyW^M?!PsPDA1itM1&z;b0RX(-Df?wZTZD;A3wwU!9c@9#TkW z@WIR7yc9^c@~IzuKOXDp zEjKuUtct9lcnA@+(QxqbK^sIXZF(EGsf*t^U(L4u-BW0=Iwo!0ue-b@(igzg zADR{10P8Yovztp>f}A)BtNChV^u!M0MGNg(%jBl{&}C$&^vnH(_iX*v*;pcIapc)e zKEbek(<=_ZfPI0&L0QuKsQ*44n6JjWigZf)2apzLoA*+E1}M2IKwuo48cOA`10mi2 znr*xN96V^c*ywodH3$S0+C$QrR2T2-dUbCx70>~9Y6(yI6|+WWS-Q;OKi1f{y;Yc9 zD6IFNL$Xk>=$&~d;EkJC#<%{nTPzkORct{0!-5dWh8e31!cayqH38G1&Vk&lVj7L19s6&2M^ zkW`vdXKwh$8c#+v#kL)Akp#)9#dFM)A z5(!Yce5H?JE21`?JSFZMqtVQj^G*BvyiS@^M*Rzr2 zRzmz05lfDqnT&P+>EfE0LlDN`C&Gl_+s@!CBGx=T5d$?(mBnnT!k53EH%4+U%@+m= zxhu7BU^`zR#wdVLT1hFB&xP?-S{ZD$ZTXM5?VqE%4c@p7&FZy&!$1Hq9#>NPS%@rT zK|>>v?MHz9?r1iW9EK{N=Am1ve^s^O`;FTl`3D*EV509c!N-lJT#8D;`}dPpR&)#$i2vzz{u!tsM7XWWSgIy?+w*!T zoT7<)stUe$T=9>OK|gMBdOXcx2P_?^PiAb2yuI%-tUS#3-#yO%{w|`pe#8|uc`rfZ zWT=Cg8Qb?>5DDAVha0gBy%tOB7bVRcuhqyCopv6ISQ^ESyjv=)V2;0NR65|X42RV3 z8a=boKPa1K=k}~FC~$k=5ael~_M!yJSw+o!cUfb>^e|wGsPt)2R_@YJ9L;|3p|b5QN1T;P*b!nW?ipKLDAqRqzQU_CC2Stqt& z9lj0L)y8D8vQ(Dapk(1Qd{gx2b$J?7o$GwfL@>7$!)7v8TcI_qI1|OikX~F+?VB z8mynX%20g`aG!th_0(&j@?J{{$cIfVvzsLXtOmV(@Ch4Ol;X(qZ>&0g_|9UJr&Z zF#I2dYr;t)Q~$ep899J_G23<%S%AQA<6(;#KQ2D6JO1N#d&arq3f^qDpcHgi8M6iM zU{{9Gt^-UL{B{(Ka1yB2#} zxo3$&Cc2xo=Dxr-FN#T^ki{)y5gp7LXVi-ZN#db6nODMlF4y!gdoq3rJ9`>HpaHb9 zJZQ~?ja0~-1_II(Y+oabKi5No2d0g-Ib~A)MCQFJyoVp8_V@j$WFJ%kp|k*daXs^Q zebN~IEWPnI?MU%_YQXg5-LFJ$I;KBJ`T`BV_0Gx3xd_o=?c?rYd1L|`LIZh1;dziF z76UrGLxo?wWrPi^Rgif43BLFFT>kB>0vyKK-LwrHzStgl1alqp&&?83%K+h2@8y$h zchX%_gt~_n|68mAIO`A^0Y;~=o-q{RpS_8~%({5s&!FpqnYhxLB$5@dhP(hWa>vJ` zwwb*jZ!g#oO<~e8*KdEogDB0m9_85jkJlHO-O%L=3Jd+Yj1PcnE(5q80SCoQG0chX z|DNB8IIv#4Rv;N2nO7-bk}psOzw~Vo0U=cAbr*NbTD?s5Ri3VPo4v;k3M4?x@GZmh zTz67cFI=F0JK=f=HOoS%=x{*JLTof*wGhK7#n^mUpSy(|K1W2v13mR4jpVw2fP~Ch z2J~F0*hSpEX%B6;+vF&J5+?QP!0Lz01=7CBrZ*I{0fF!P(FeSw-Z_(*OUua-^HI9& z`b-i;e_u$0)W!1djBLn>A~^?%mQbyXnC89YTghq3_GNwY=M+Kk!$Fb;#iYPauH%1- zfgU@=|IG>iyI-j!7X@C`x~4xTEu#L<#yKkL7o}<>7BFiCZNsbI_y4Tyore7x!i?YY>y2^Tue}OvKi2 z($OZMM^pzWdeK#|jdCEC?T_}oQXdt=X3qc{{c7VejzMoep!(|_P8UE^ee)G)N;Ph7 zuYgbnr!*#B3C!{3r1&;0XpzU^J3m`WRb%P>kvTc7oa*<9bIQD}12yTGre8!`u++_1 z8M195q_m>kg0?WDQOIV(Gs3DLzsaHm#uYIlH#lpq5d8RoUXb5?H6jyn^$oCz7j^x# z_E%b_G^V$+;8eD~m@9J5a3EN*Zef3i0tIYPytBW)^?$_9Z)5m>$)LW1?LL|T23Y{m zu#Y}>Syp&22&wOj+dF9zwXR?AMI-he2|n};(lCpLti9Hnn5Jp4i_BBjv=o%&NbQ+I z=}PZ&a5aa3CT5L)XgIkjIZv#+_bXalI%WtF3HHV8^2N@GRLV20&>b?4#^}~#vkm<^ z;_$1F=D!}-qYFS#$^(r1%;T`-8(6Y9U&1()ov%22(u$W7Z$cbfH*(`czw;;ypxVE+ zNf9^%w$G>fx5?fJMI<~@k+#o~fK}#aSTXb8DR?UBzr?2-YDAf#9}O<1i8)dX#7K#K z6a#E=G%4eDPgRAJn5932UyGv!k;{?0Si3QxYC5$UWq)9(#=37;hmFVljA3TVYcTHX zW3{k(6?UtEDiKNPnQ-cc)7)`@MH(%PLU#Ky*YwtQu;^a+(&Na%W_VJ2Xc9?U87Eg9 znu?W&2BKm<9+q;fHnDP3{WdP)rsbw}X3v{R#w8<}{mH5DE#f4dKCRym-aRDqh)_wC zus=n@!V08OVpfiRS5Wz~dK>*|EIsKxInvX=yPsF6ZQ- zw5!gf))Qua{yXtJOn0Wl<8;6C_|rxDpT|cDlNWJG*~ZHuVUjc7!6<*WwxRscKW0`p z7W&h*TG=S7FMZlBjU-gtDat!7j7|9`1gj+{4BjE{(LCL&z>EpEmxX7ZzlA`P3jO>C zu_TVfhx04?u$9tAS3;{;)Xj#sPrt&(^p3Qu7k*ujn3vAAsy7j-_x7uOmH4_o*eW?k z7d&|36`3R7eZBNRI#;>Tn@Z*UynD7Jv|Rnro9go7I#DLYc{2MrnW5u>=yF2p`52lW zZ9VtAJ53^#ET~@nA1q*@`DiTU)0p7X?sj3FV2#6%A3yZ9c7B=?V$^4#GJBs zRILs?=sJLfH*AV3qiWWds-fd0ZGy>`pvr+l$eHYHsQGv5j{mLncV)?|=8%0i$FSDt zYSW7Q-X_wQNxkK}73=x~@RI8Y=!xv+X$HJ_Tliws32IqeR`OgcWAe1xXhh7YziBU> zd78QxYFWz=YC0=>AwAn_dOjRQYd6{5(TI~UAzHIc?+ISn@%z)2wg&xLRKrbUg z3so-6)_8oO3lg;RA*{G#k~n|p)>o+Mxw;LS<&WHb-zxE+`IoAD;#VHT#s0Gr|9LUK zc6jxt9jN6k*Ym|?jpk`5Ms6@9wEANI_ul9*yA#CH%-#WumsgV9d{*^&?N*!3+Uf(s zx#>f0QP{rbOZ3&+8f8K6AH&TlQfm?kE(n@bYb%HQ>3iJ2YGuzmsJX7`lDZPj=NbpW z6E`kFh`BIiVs;-^=*Wm~qj<3XDpntbR2?Z+--UUCJs0ZTVuivr=;I}1C2+ ziVd3|Of-34_I<&4+1w+0cI_1)s#|IBUTDEMa$j8|!LVGs&qd69T~(z@$DaC~+YiS} z_B%^Tj~D4mo+nYIE{jiHUKso}-cQq4H-gWX6CSlk#Gae&M?}YN>xI!r5%A%Z$3MT7 z?{4pW-&s1+@-N!1!xt!JzG8xBRAhI3=Uaoc&&S9A?zPvImE{4SWBKwo z+H2@Vx5y(C7*iQepwZh+m^6al9-3scA|ww#iFy z-nlE8(nLa4uq-V52bBsI#}4Imm?F#LJjnj`*5`i+yYfJ&)Bpb^ZRwJb%GFZ2sU+l> z+09WA$rW-ex$kq_W45j2T$>_yBy#4yjdGP+$bF70$H+BA4osJa7^4oU#!KBuC_NrS;)Um za-ElnK2(w5eYiNBf2!CtuKuz{(-mV&RDU=tZX&I@Y~6+50Jo&b+!*&IJT+WEdO}c3 zuzbP>{4g~sIo7%H^D|TAh-fn5bCj08$HM5NC9^AP4*d>J`c7Xh>^uivW|g@0EF3nI zHG0@|MLn!Zy;gcra7hv!9b%lqB**9Pb#+h;1D#Uwxm=`J@FI zxv9_gJx7}_{lPj}+{Py4TvwONKQ-6BwjRN_vgjw}{5MQ!UTcmMe(UY2r&lhQdzk*N z#BzFo_!6d};m}D4Rh8ro66i(;vew{@&T=I63pz6FlPkOH|7s&-`K|Z3;)}-8#qj_G z?}bm{(Yfa6^bUh#(LVKo4f?MAR{HMsa+5JdW8K=^Y@UrR25yVbnd7|B^^crPRc7XA z3>@qG8-(>2OSA-$qvOe}VsHS3Wa^jQ(H{+vV7 z(|CveKGnY2aA5;NC@@Dsc3!CEukBV*udB(Hisqn_U*%2i7-y1K@?Pom$1;r0U7)Fz zt4tuXDx1k%L%Y`XW*YrjsNwznpO-WthOhmfsiua6`0=zA;@sbPm};uO3}A$h`>9Fj z^Tt#|^g!|ov&fq;kvG*h5|LT6^D|LblcEO=Oqh-tgiEhN%-^(H9B7*3ecMQmECrKX$+-O@-{j3d z5U`;wI}vK~fb~*G`Y#6p-XkU|4MoM(HLlA!rJOpIf|<2J4|K7zrayk1ia<X+irm*=Fu zz*lxnarkeT)BF9*Qf6p?3Y4cGFggk^09K;aj?r#=G5`W`4+JUWjmK0a?XEiQmo99Qy31sed#j1l zf4K^Ey(z)vYVX9mJ4_Agy!ok%CWoTsPiJ+Q%Z3G2 z6W<|>hOO~9=99S`mNxxfHfM!#5qv1i9B(&ke9j-6@!L^SJhjaf6zB089B8X2T|iu# zXkrOxxfce2@r27zXlv9(Ug~Ny+gTm^+FwI>^;vCmX6ZBV?TJq0vmh8P=9A|e|jsbta)Hcq%@KT4rIIhgR zsT-L+mG?{RQ1t*8XOek9S$Jqn&2LANI5{R^KK1sM%6aUGk+OP^ZfV9{)i62B*O#BG=GukHX}^(|O$^NO4wkpP;_tUn zN`Ke->`<|{Meb?X+fjLq^!`8=jWwo8xvoR9p#)=qg9DJ&_i*Ao5x)u;RQjT>b{8*! ziIw2|I+Nt%vnq5Jd|00g-uA7OJygWJ#T$EZ>hTe!tKooQ&x9}DO$oMik!5<)@)WPD zBp1rXWO4@mTK!5wod1Z{{g*9_a+c+fA&!s|3Gi7xQNg1s4pigm1I8hOGIkH#(nbHN zdlI66`-r{q_QqO1(TlbEM(`>Bhnli5_-jt(H;lJ&Fy88dNV%{^)C7GK->pRp9=<6J zdZzXFntxQ_CkGWwq~#w+s%i^IuJ!g`O)KIV%=qAai#I}NCY)EaKBtYRKF2)lEmB~; z-ul!N6TW&*WNL!>KxU_AaZuI0i@jdGrBVAqQw-8;Z4tdni?ektfbGI0@z8y@kw+Y> zl?OInt|gA1ItPu{+CO%CQ)fJWkJp2LeX4%`rZ4n1O=`*)1-|0J=!RywdK;^E15cgi zZtyb4IweVrFFJ8`#X3FpD5@WS_Ravh*A!~0LCx@_X zE~ASIo*b(0*`Ic#-_1SE(xf^j8sIkm9L2@)re9?(e#Pfa*EMU=OD}Y?I4|D{b;(V6 zoXUeN^k2|m{F-=?!zOI*z!S$FnOfAvgdPE-(L2wR>kO6rQj8w4X5^>w)%eq!gw``= z8ImmR+zAqd07$Nx>lT2Z7=dQ~n`#prQx6b1&&15^fTo?geU>?3$xVoUGRJ{+ zGVW}aW4ZYHnh0_!Or0^~va@=^6Mn2}Df_9$+yoi6Wd9WCBu}J9uC@lf#`8XLb2<8? zDb3_F8BH)d6~FQ;X%YRg1;}<8>q0pJnGf8N3?QRw8yk=6=y7R zW<*UWr?JO%S-?_FJFPWUV?+M*W?EUnjGan1VmZw>n5~p{jUyk+$;6vK$h%+Z9#h*> z?BILLb~PNkr%}s9GDc3S*xPW(Fjr}WEmSx?)eFma#3|WdYcNU-5zK6kU*s?ezrJ8) z8a8sB?Q#e?FgBE;8HHSz*DDZcPeHAm3LM3Q*QlXd8nf!!!}e2AyD#df2)pd^D zZR6=cAB+wodN(n2v{Wj)w(^eCMFr9B#F-qijD5+wEQ>PTr3)&hQxt8VrQ;bcVa;lKj zWew{zzEr*%lQzxbUVTOfZThd_84%C2nf!OldK2H8U#WMzRc!Xoy$i46J(9&yf`0QP zt8`5vu5|5H6cYcjDr8P+twoS$(GlqKdXalDXV(3i^)`B%Yxm=kq4EPGvEJ7cgH@K} zaUc-cay+bOFN$|d4Ntwag%3>`bV!RN;yl5XlMxWhcy?-Ntr73J5UavS*oqZ4{A-in zQe5%Y#K1@FRMdK0e+RUxh6LYr=1=&>o)`&!EnNL_bUEnLei6aX4|FQaZ zy|Y#1k{6#!z{^B=)gv0bLV*ofg$g~Vu?n1|2K^`5>amTeeW^tkL&oaefE{L%6kg=e z=1^O73ivvw7OKUnz#!*cEv^~^4fW|YPZ+~*mpy!A1)vzx%Yna}qp^eEpqR-qLg@Rdsi){T{8lj_E;(Y#Lj$*&0 zeh7eS9aMUGH@Ku_!1I6t+kEeDEZp1t?D)+P*NjkYkLc|>?5#G{GtD+k&sTWzw3;>;CR9HM zXPY}QYG`Zl9)5lQjxvCy#b7$&uqM0YWW>wDq5L)eLK)}DeJZwn(%q?klO^uyr(MwZ zEX5-=L=>EeRy^3n;X5M4^bNyYiB|vjrnzi$Rjz$!+zBCV%vGYRrZSy6+%G&dub|#& zbOxHsv(TQDlYC^f%7jZ#<-0}5{(70o;p$+hVVh8IT^*G}V`GJ)kkr@Ih8LOlyp-Su zgb`dv$s4{=n3L5axfbAmc~S2oY`TUy_FI6Kfb`_@ns*Q*N+Q+ree~P#XxMCDmd?ft z{*%bBi>!7<8)F7VvtwG(@P68*VmuB}&2;NI&gCViTc>xSzcZOE!pyZBi!7k)316oqd6ze*IMk)`e;n^ zb(p_}TK!>7A#wQ1f{3XSZUXD$Iv~hmOTZ}*Fu0IGSDf*XK5n%T?Iti9WyMiyGiO{l zNa)TeUNTQ3u8P@)bT0?>iFu^OrAa~F1^0i?Sl7}*fxbt-10$h&>0 zMuXmpgD9{6EnC-k+%G?*W<<;uE}*ci8&hOlErxd6_>v!GTbjt!rd|3q3TC~oSsfFF zbeWtuMJ(+TBhG<&OY=h@-=47*I~q(Vr6tt&oGc!ic&o$VWU5@gXc92w++$&u9?>;4 zWIi!e|Mv$$1=VU;#mUB(u3uXO-P^~LW1OZx5H2$>rfoc-dR;7n6V~?bkK})WMERRo zW!_(G))FR+cL}+-%!DIR^-289EzQJ<6o30*Wq(}-q=1_CN#Ifg3vDxhZEd5@eY5i1 zVi~DhIL~@Y{A1jQ-ZoyBhKbo{+qN16FSuW#`e1C!FpcJDnf3V^sc1>(o_?M|15nrF z*cd)Av=9uFWqGtAq*&=n5?H(z&Dc$nsyq?A!;WDR0;a3-^(U1-`hx0(Zo(_ZPnsJ@G^=ojA6F#eWK- zpMk86gJ~8Bek!uK@hPcqpM)XbxZBniYas;iujCIDNB_G8q7t8){Y?ymkPd~*2$K zv80JRp^we;`fu3Ot?`8so)v*z^r9T82q6xD9CVpK-MEG z=^yaxX0Ucgn5u^)DHu&c?PtkP^>bsg*gbVr4jUU|%Wb$n2m<2OzNSV~s@nyBp3jAr z8**tj6KdK8JiiwS1iUiMH#5l97EM;-$Ke_-%c}_p9CSuhMjNlw6*QP?NS7NAp>$#M zy&~clPa$)TU2St?qZ!-w9nO)J!*}wHyM|vRW32lm?$p4uSs%cc%v2;kp03(Xoe-0> zr+@-L>kjku!8W*Zeglq~|L=y@OD8)=!V=t~>#wjnoKk%u#}^<^7@3c_8(5YQmk3FE z#;lC1(Td(^!$#xaUc(=VcmqARIh^on6E==GKL{H{dJ@-=3l&Q~xlG7~!6hG5`D9_Q z6CCAj249ZMg{%*D76&zpni1DUC&3G-IGYLiu(&0wd40Uqylq_%5)+#X$4eL6xHPX# zc`lA*88CFkF11;aKDFS%zqKjRN$+K8@MjnJ33H__L8~|N<|PWH7iZ>W<`d^-uwsKw z&30vzvqN6k9^~mcPfcPeEQ`=&K9sxOi;ec46HSA-HceX%d1Cqu%B|b_D+HrreWti| z5B&0r4*Z@EBXgg4vD&zu-FQ`Kf=^mpYM3MUp$+W|mJ7wlhpZ>oad(7}Vu{uq;lYhh zK!E5y17-`*st2ER7`*XhHPN>4-YH1m5hOt~Ql@)EF# z-}tfF_*bSY9T@MZxc;TwrcxRNGxIkr?UpaPC{G;~+g)xmS>e5JVMugAu4n2JOcQQU z1@{UJHYVk@q-b1)$_i8(4a;ci7j(KyNI!ufTyGcij7D9dSH01!s;P?H%p)%|1zpb9 zuW~Zd@dpVhquf^u@A2%VH!&Wg=^71_>H3$j{c4l3PQ7JLDp9aOi=`Gx>AK?N7RY2C zt6AJN_=^9U*~M25@v{i?eYc4}!!z&O`&6Gk-0XR?UwWcZ8k^VsxxXx% z$*JY(JsE7YpNUh8p3G$bcy0^C2GgJ^DDJW_3o5$n=-#*i{O~i+1CQ71Q<06G%e+Ao zVHKc-Y1m=$qvvf_zV0AKx_E+awp6by{>){!n?Uz$nhBN&QHEg7$0E zj;{e>Rv0-|IY@M(=@3uAWu|@mnhwAVmh38U-6+UF_Qqc?(^R4)=OYN0l^J=%_9HTn zM?gGp8N?xtOGmX=Ixd{w46$eqmia;92B!@^mVZ2#OwO}o1Px(83U0yBwP@of;A!d* zf~Q~MtsoO`ef6CA>jZ(+%Sq~E%6$G)a=K$Mr7VB*gjH6Qs$b?^CXKHN=CZF7+HQE$ zo21!<1?5;@aSSu@H_tnk`0093g*^Xy!kpDgB3rP$*VT0vZ@hO#0sWKWK4vv1q<420 z4{rUgEZiXS^NqG+3S8sTSMDYboE}t~ZV(ePrZe%$$Fysm$wn;4j24qdg0oqr~QzCOHZ% z*P<=(CVU`aeFoKj^X#N@h(X9eo`vUm`;XKYHWOHc&Y@-O^M&*GUvAVX2y$e--l_#5 zs+jFbDH=V`%s+loKnmv(e9VIZ>eXVFceTST#Lt+pKTU9@&)Rr4+O+EBqnf0m z0}3*!pIh%-62EsuR#N?ltl7J6vF_<3NvEvq3^l69`Zd?Y{&yuY$S+C$1%Cp^{65Zn z`P5L0OP4bJrH|%^pRpV;itq07$9N~&%$+-WCE$IxO76#JX`JY&MD1Ctll!jo7KnLU z5+9`nmyR;=Pc+9B+g3dj!#9Q4mW{ufv2*|SI127o)kLSy;n0p3^D?PA3eR6yNA|i^ z8A#h#X|Wd(-UM|GInN&z8*)K4!3ONx2gLeW9aNk%iS^}TUgLu^NXMZSu>ovS6dE_$ zL_d>=518^=sSuvHN37Ak>b3A9$e?T-Aw00y8eW0<7DQaC6C1#M>|Ie|Ew%4u5t96x z8W0R|c%PSWV?k>~EG z<7e-%P(1}_XrHLZ&kYUD0fn%SpynEieS23`u1BV8wt@2yX2Cn^a?&cMDU2YW+tXj{ zX>Ojv*O0=c1^(o=&}jTAcR6}#&Jgvtsr7uRs~VP9xg3+qdITahf>Up4sMg43L;EvT zK~)YO4#lSs^fxRREmoy5BKrC2144)rBF z%_~Bs=9o@f;MF%ZngKZy>arONqQa72=dz^dCJj73WIH7vJ*wX4dPJ(&W{5#$-gES= zw~9y_^+{tEL=h`LHM>6qZ(JerKN15IY;vp|77!&N%BgY)HRkb?I3MR3bVBMG z6MCCYYhq`5YRXrRQ=m>T;Rkb$Y-b zDF5E} zw3JhUXm?3jV|X$q%}=YJ9~~8`5jy|8&#O=TBS%x}nWvnlvekc?Hij&@$0XcnOEXpG zRgnh$cawr1sG2(D+@wm;`e%l&vgy~b`#yK`>?+2p12hFm?#yYZb@}bMc5(}Re2u%z zx-(lCV%1iY#;oUv`JmXW?e?iBz=semmn9`u*tWLjbh5%|B|$5_8I>jOsakzLEu?&G z)>v3#vU}=&*)^d+wZlT!oCbRuOgSWzky(M#)tJ--zmG5Fl}T)7?!oRf-~R4xJ`XEn2h>N^9EjmAe#Z))LnE*m~Je{ZLC(D;4(t)D_+2gA&8 zy>H32fPw7WrRPApCPJ;6-kdb*8YAB2zw~ysasS*)wKHZhSFz`lg}V!_T8L$HBS=DX z3%iGPSGy$WDHM6uht3cz=PDU0!x?!6}KeuCs%M5Lfi+ZjNk(X0Or zQ^_j74>mVYAd*FNcGjVfNp_tmRltn}m#gF%;|944<_Ah_=D#Fktu5*bq;{|u1v&jT^PPfFN%QX;ON^J8?I*xX!ic}!9909 z856FSeB})$d82Mvg*P#p7#(p*ly1Ux)!KNy!_Tp|qqc+Bq&kdnIRP1Z*M~8~(c6b+ zjg4!qDhk?lXj!ZtHE~I6S={S`*J!~Arb8=p<5RHmME@2@^G!_Dz ztb@So>cBIq*r}@yY2P(ZtkFXfAN8-TPR`da6uWSQwhS!9Wd_{Cm3C#|2U-RRlJs$(=Ir3E;2$QKSjZMv zyae%CZzg_wD+!-boAH@xhv9K512{Oi!9KLU>`iPjL*ql@EMQmf!g2M2+6;d-?Uafp1Ot zAF8<_!k4~wbq?h8J zx;S(WsAQJTJe8cUPaDX#UFvnwwM9BD(V@FHTCAMB;Byo6Sp&=E98&BHcn<4z!d%qi zDjOJLywoenbu~k;&|)qoBufvw$~JR#z#Tim3bWCNVc+CVVD3A}v4i?UhC}56ge4XtK@cFu;C@gI@GW#_M6Ia7w&Z}<{Hqx-+-vVUCmOWCn z(dJS3SeHS!JmO^AHHH;jj=)`Tl%>Guhy)?;rk_U~JN|AzS>3ksLTEgJ`f~bnsJgD@ z^NlPkxzQapu9)RMQ(ss?Sk^Kq zSO~~JMAx%i%v8P5+xYX^_IDNQ4xfi@F zVZgv~?YyE;>AAuTHASHo)Y`Wkt?Jd$2BBvDu_Ph=6N+;8dv&h%DxxZwV;1Yf>FSb% zAxWY|;ujPqHJF7xJLMXL%6WRn!}YJ65H@hN2u{lKTFdeKvYMl%SGK$u@Iv3=(`SBR zZO8E%#mqAEh3Cw|6^jXK`c9udY3VzE{;D{6dxqbv**x04x1V1~VxcoU`WsM<)pbRc zwQ(^4&v}*?_gx8pq9!rnZ+X=C8dyhOET-Lf%a+mNiWKml3X}7{A$J%o z%s0xEv_9fzcD(M-EjN=A;;TdstG44S#QNcn?-*$6mAn6v27(xF-S0SF#N$|Aq&>Fi z9crK|JjkJ1z+rjPBvkpnILgF~4ci-Mu<@8wfcCGkt5^&ZGjLywa$OW3z*Er`drk_1}R4d)BeY|1Fc@3;sAXPi$wf4DitVzIqhDmYIb#1iJAlt3!Ie4u3 zXQ}1BxRa%E^CfQx4D%#Y5lUF%KdJd9KOh|pIKU0+#di_{mb;0+!-IHIjwpQ zi`O==#YgVlWPV-Drbf$eouF+;`QY@%Af8!7!1Iy?=pJnyr{!r#$*?5@V# zpZGgRL<3CI*eu_eiX1)0uJ+@Nv8vSkCzcO%Wb*CoZ2Pd_&WMiFFR5Nb z=BNq>thb!i?`dp2m}AwZsooE3Dxsq9vWkU!N6c%}Qc}a}U!}61s$W?%RXr-2_Ue^o z%Avm#R*+0QcV{NQrlsnogz!6Z4@wxx6N+(P0?&FY{{^!;Clu7cWn`OX6J$vS#{jos z-A;zH^#F>+B|O(na6%sU3U2vA70)KAzsaHRoOzWDJ9gygID{WE{Y5pS`TTUM|6RhJ zyo^-mv_@8wSsux74OLAI+0;}X<-%UC?lYg@P%pl_vNH$T8qAj@^Y!N*<_PG7F8*O9 zCFXFP!^E10bKJ=x!XE=g*OmsFs7WmPBh-T%1Di$676ZM)R3>RN4+w0=m`xP0z_c_9h>~d;AXl=ieKzg@BDOh`Ao1rm0mwd+1AhvY2Vp zY+t^oJ%|-NEW0Wa%un(%XQc6TDg;%q6$`P=v(_L`4p%Lk>D%1($CyUdWoOha^Bvsv z<5NcM!)|E!e>4ji$>lu}oP!aeN4bZNz6`y0@fTeUEFWxCz22-3UuKy4xKF&(U%XTQ z0i65TQgp&8R!bQWCRZTde^tYoD%mYRmwU4;{p}hw1)7KBPxJM0gw@i5PExhANu$+rBARj8hVN1<*6<2Q_&kFo z_Q*=OMxyx}R*lfq6thC36rRW$D$#jH{Cy|(*yB$m?h5@K{+2I5QaZiO%48s@KiD3=8*n=C>6y3I>EzG1qWntulcH0+A-6u;H8i0sSSnYPtH8PPMrd+%y5ENYG6xJ#?C*~xf7sUR zDH+7##X{?AfJqm$&rW~f7LIiIx>m;q-?$WF)9bxt11VWK5Q4XEh?U-Vec!|D`>roo z9ur+kvpm^k+PR)~`npD-JV?0%$4}K)HNAS}XURh+d$YuUsmZOzEG@&|G}!BnyTpmL>s>9563a6}nc4YwfBPDB)n}TYo<#=#d?k)>E00B{ z-4f#6{26-RX$gd0~)+BGkLg}l&dESUj*qJ1_pQD#1viO|D#^-tDPvoeVr zR11%EzYuG>bQV^Q8m#J6*R~8TR!-WkjZA`4pPB)F1Wv5S$05fF^IpVIf<-6jE43dv zy)@Z8o2C;|rUZJQOKIo_s%h6g#WI+$z8@V+Z zVTW%l64%L|qcwoq@xphSr>_)`YrqJD)-a&(fSzOnU zSBm%5bpzr=Ra@Mo-)rnchof+x1cbMJL+J*UzN$D6h6JTE4AuXgG>L3*QfABqi~ zbTWE9P#M3tq&$~u*(D?~8Y>sQ4#iE8b*JrI$k+9M!yvA&IZAFwh#(5-l|71)k>Q9lA zWy_8Gh4p$YwZ<`jY8)26mmkk|Y3@VrN&O;^k(QY9Zzl9<%oXWOkV4W&rtD$xt8(Yg zgz(t>i<}1Fqy!wAzzI&q@7MT^%h)p396qH!av0ffCL{%^OIXqplCm5B;MC%Fp;^+n zZ7rk7a6I8l^rOkiAi-;Jjq2>ltZSG*NA5Yle;J^|kYH0&|7bF%_*=gDZ?Kl>+R z!BLCaWOTj}Mk2YTVtK>3tIVSZ>4iHz?>@KqJh?IQb2#yBEdSJ+UNt`xZ?Eg?FZ}Bo z9V>nFnthRNmZSG&opmqe$R9bX@UhU7D|u8TLHdg=s@_#$`)CAkEdKg_>m5tD1x@Ej zsL(g(-q;dv2u8)XvijhadZUqk-tbu1tY{Ww<+@e#YHO(`ep+~ZwN*H4z0*KOq^`7i zqg7IOAy@>m9IQc)TyO68nPM{|Sjx)e^T`vMNW*V4CTOirN!w@f(qwuBtl7UfO{-Bm z->dm$!$i8&*-?9Vb&ntZ$)&l#q+BO~m-v5Jog7QuFp9A(84FT>V7_KteG^mRhWQ#0 zB5_tnYdrUoMBQn{$@KfzJjZib&4gmuvK>ENGCNW?`&_ZtLFq^VD$pz@GS~5D^=#Si z`ex1bn(}5sQ7jH$7TwLVzyaV|nVL#;{=(LwUADGj2z+td++8zB|`BLK&5Mc*qrCc{Qo3Qq&$a$ z+`?##=@BYLL!qYxnZ+6`|B^0o0CL%q`+~Evlw^2?y!x=(wL1@FYq@v$Nkv_L;jF=) zM0x5i=*koFwttpu13UknHJlbcdwtK~hy#< zu~lq|!7QL`xfKtj6jr`|fNg&mHpkqEjtavU@lxzX^yHm_tc9xQy?-2xxJCK`+j;xX zA9xedlgcJIRlOtRsPQ0cLgk23>3Cd0@HPC{qjq^VF$!Wpc@E42F}lWl;?(WRQ>6I= zA5~g#-{x?6iG!EmzT~R}dDNfXSr>7MvB+ianF{L|vDLZvQ+t5X5jzuJ^3F-a80#^F z3ujwjQoG6l2*;ijH>*kis>YIdqJObGBZPHKg`dr(;u#|jUw8@EIE%WpZeGoC>PXVBaKtP>-1wzkbyB52UUH}X5LED+K+S4kV|g})+~Isdb68`t zS)%(WN;gH!$8(9e_Vo#Ip~5C)^%L&lrPJh2yW`@lh>X5pY|BxfAgha9V5Fkgd-r#W z2E?4T1Ckoy4(=3Q0c5YBo!l4wVt@y{9Co_a1~wLJ<6jwYaCIER36*y8ct1;MXoxdH}Xy%*5O<09 zmD!OGXjKx5w}ww?nqvN{?Ejib%nIWekm+VvgK7M8RaXn4P)HXT+1qcCNL*xf$$9T_ zu#8}`1kjbHAe6IpW&{?Uaz(6uzET05!w8f(60dGGJ(H9%{s0Zo!Qo#O7wl;!*j@{* zepAC8Gdt~6daKe@Qps>G(Nlh)t^X?_MwCw}=0LKFAY|jqGgM!qi}GFDoy~wN7zSar z;cwcL*PE&bzU3V&QQ+RybmvUp6E`MhSR|tu5vY*U__}LI9BKf(vihbwr=E*i6x%)o zH;hhtV{8gy1OM=wk9+WYJ;Ap3)Q3&6#54nO2Si-){IJ;rtL^204XqAO%n-hXvx<95 zL#JAKWfytIfK!l(*C%If5h5p*Pm6*BOSlaxSXJfpxr-CjSebl`O&Do*N%=K*hm zui3uCP$vsSuKxjXGQiadkRc3BJ|x>DQ^6V}d`r^hA*Qp58l$|ezo6$r7TV(@`+*zr zqB6%F-;TSRhU=dQR=|3pUd6PqxhTay+h1a1H{@nN$rgfUbLnzQey%vV-u6P(#>2=S z0At8wJ_mrKi=pS%KU+2d2*H>}2m(Xr&+D)n1iO;XRx4Aef7vO=mgqx!DrPud(bV1_ zPN)$%Rxn{hhhAUm1Gyl!_5FoY+;`Lv4k|ORxUu*(y4aGrS9%9Z+f7J{7q&1+@7Bl9 zO!(p%uAk@VI{#Wgjm#~?O?seqfq!~?Evyx22}R1}U*h$&SEGK$ISlSeY{UK8dB9NZ zMRZ>=wJ^kFz2D*hK560Gvwa-IC)^2c@R^5_T?FewP$_#y6AM|JVS&lMC#V%d@ZZ$- z9l%ZgzVVOL{9hIaB(2JlX-+nxiOgDTSWk|71T=q0&^rFp;bi4Fk5;%5HZ1VL&TfP6 zo~?KnC@gFMwA?fE8X8gtvtXP>1+*mIZy%@e(1K3vQ1^s)SaRSAt*HmyL8TeREJ^P# zhq=$x;B+~9SHiJ#$3aJCQY8$YgWG=yGiUHLaU;bW_g8@yxYcJW9k}WnNf~x^nCB+0{RDwUA$4h#{02-f?WR51Mt52b4;ly_o!i zHvRsUmHmX2jVhh`2jFb>E)Y_C-IBswh$BpB$G~i>VhObJBzdcB0IfqKbRRJnZV$U8 zH{HK#SO0ooFo^cJIjulU7qO&We@q2wej60xDBUP&B2t}_97kM~0oDkGvh373ST-}; z!_Ry>LyqsCv#rmmCu79t^wP{=<6lBd+cW>e8CK>3BM?_J;#-NJZePEvjutkMS*rZa z8|13;!#U$3gE@;4U-1NKm+C>G9apx!pxx@&&M5_imSEI^op3C&ks|^U zT>Ij}HvvS_()tkmeSgD?H8znJqnFkD3cpWQcBeAD3nByV=R-}UvWA1pwYcWECBor} z7}$qAp`IL@6Cjzk@P4cJ4o+Ni3U8j9ub`P-mD{hhW9|M$+VBmq!4TOd(Q@8*&bC9k z%~BJEnfDl1a9uGnGYM*M6qpP)@BS_*^7{N%PW-c&h#6fwgehNB>8fh!W!eGU)&rn>UD(!yk8UP;Aeig7iH?!uqfukY-3k^P}ytRMZTD!#+D7wjQ>biWp?LQ!rm{ zCS?5G%DXg^M`qH4k&y}}i90jJa2qP@eAqkf0*Hw??EaY9txNwa9Nkr`17K#z!N5Ou zNf_WUZ295L&;fv~8I0`sP8tq16rf4}ZI|3lBfljOXFtHUeV3#{7NGP*uiuKSV zx+c)justVf%Z85o`(yqkoyuyH6l;jX7qskNNW=;b`~7=x0GOGWAbWvwUVuww=68@? zFm4hLZl52^w;n1#-7E3k&h1mEC%%UigV4nh6C7(M}7Ji8n6O&=uh{a{%hM?W@2}(%2Qy{ zkV{kd1k8?pzwK=UM6qWZki!*Rm)8A>9#K%?TnyAC98&qTr=oN(hpDKyl#XJsQZN9| zSP^zwZ+tgg-lB2Gypl<_zXuTdq{hTC`C#_}3erf^k$||9xq(&V#Yho1N}^%WkecG2 z<|aLqetlEp*aSg{&=yiRQKC?({JrQXf`HAzD3De`*i*lpvmc|*{9YVnD^CV2>ser4 z{`Xi2agFxlu3l}4hHSXEx0mt|u#GJ?f%RZuvTy!>Q-Qb%6gy7VtMEdrE_Kd-#2loR zju}9E2;Z9S|K?jccBn}5KN&amD-bigNB7=itgBS^zedj7MN&_C?ead-44pFc6cV8VJ#;ayxt2?LW2`uLzJ?I`&)71rmAAZ+!K>HzO2EV(3Lu=dP~lO%@Ed z(lnC#hbTozfjE%PfL@XuaOIXXWPs0b=;w^{B z;jn7sci>ow#Bl?vmp>=j{t6&MA#qY?%cK~N()9n-6oH065Z;d&hhN#;(Ub{Q@lP2y zbpgoP)bztI1S?l|{*22p;MV1kM9T+dvS#dDYTc!qY_}>^fiiK3 zzS>V7OH->`x&Gss5rd#9Y<|E@*lBxv(0SUAV9VQ|wjQ>vOREdmoF4Y0_0I1N7x&NZ z;Tl+Ed+>&c!;Z08xp9#jg zc;_tcqSyW$btYxJL!x>M%h!|uG^^PC<|vkY;w!Mjn5~B`TZG64p%Vv-LfY=wvmjN| z{GmydCS`&s_`Vx)JG=r3=11~1H!_ws_vf4ptT9YRtdue^%%SPuIe^_};pSi^5E$bB zax6VfDFS&KFG`0*8owDCUMGdUjOU#w_tRQT?fUQD1Q5TGYLzRf=d6?yAJeN;`T>_` zWdSsmz4$PcArQj$7%6VN{^s)Du?L+aeeo2M-z0HB_?wXl4&8F+d$&;;Kr6oe?v9=BC`DHO^?Z(7 z@VXd#L8&t6dtOvAO;zOJM@-Er>xLv^TfM1W$uCXW)@={@_$Lh*>7~H)-v#C# z_n=$Wd`Ai6+ew)lfmR!_gUhXlZB3)#e5E+ajWW!J3e24O0S3PFXDt9e@B#(t;0Hg> zly=wvgvS5?5h?X?;bv9pzuCW?h?Dv8j32nsVnVqi%>73Y-y>>P(#Z>bcCq=-l!t-{ z5X{tL%2&!vBsqtFsFjpsnZQ`Rg$U~$r~R{m{}1CX#)849w^lZw%4@jkcPVBfn_%)B zhKzs}s&|giPnlAAQsyb70Tk|b^W(XAYlGxuUS?`zUc;T%-E9Vt(|Gd7eZcWc4*%Ls zjqo`9F_^p@r6Jj+O570;0nR$kgEXFhc((|=C7V4F5U4H#{CIwQrT7w>b@=11aobv9 zFNmB&fL+5%@k^eE#<$bVZr!uqU3Tx_z)~Q)B8F1Zy9D&XADRA7#Mxg26hHiQ3UCaH zrR^rgZe+ZW9`{j+y;V8RYnsPdj^ zzl#RJo8>_CK|AG0T0G()6?R)2cPOEdA2f>;eM~!?CI>E-_^NI`?5Z&(Fv{Wqj}I@3Z(f(CDUqv`A%&1T zwqZ}uokDU-(s60&n@d)P9xD1diV(r13PeiXA0nIEikG3j{9|`ZnhO+DK$!V^+nFsE zRIbwAd-)wc^NV7{$+dtTlt5KL)Ph|`g-dNpd+Iz@@lOrHlsw>yt(WEY&6|Tt6z_2( zi)Tmf5l}Fgw%V0{n{%LK`jcRY1kL*(!Kn#qB(u%Az;}QgpM5`b$31|&S~@MuZDAv- z7%q}K1j$?{Z!dm{q_FcZyXm*F9N)Kp_Gjv4*hpew;OF0WP-HAZN4@$~dYT_wC45CVkfRcjZ0a89c(9(jYP^AbSDlEl! zH>uv--ZM_%%y?&QDcm%4{?Nzh@9@6~3RY6JV#NFUrhiBAPPI+iNeVto3m_cp z*7y&lQ?7i!Kl%q$R4D_|&5tD;lU@QuhVgNb@_7Crkgak!4r1N|>WX_*t+MT4(Z7pm zzDMFfxCyWQeQ(ux`utgze?wFBBZ%I9p;J9XNyOQU71Wyl_JSAPL8qZgY`Nr~NDrDt z`|;lqa^)XpDc11r2c*|PP;&K|;PAgfJ#U(0ngv-ZkN;(?j$JVPUL+CD2nXRl6_3YP z_s$EB;!Ay5cKNuqtO8%FG;pa+tuA}ENAgw&yD1w4z^|ty5bOed*nW{07xw~p>NhIh zJv#_@yS3b!pllY#7ox_eD`OAaaKgU3lq{7_yU5;e? zw8PAJQ=mtpp7F-df;D!&zx6=Y_ucibT?cw35_??__N(y!M=~N2I1vb+46{m@FYbGW z?ilNBu>u#`Qy~`TId3XRPE5(trgV6uX`osljD>Zga(aHEJbQFT76bq)f8}@{7ccVN z@@6|b-&+d;FhDQ^<$zPMCWBkezU6x~1Q7Eb$u6YwI7;m~10s>$ez$*UOJaKh77*h9 zMlx*67p)&b%V{3`yjZfiW!7?FC)!wa5dT9gFufzSqf&}x@61zw06W3%vwD}i)rxxY z2l`G?I356}6+_-Fjls0XBdn>g|5i)N&Krgc0BwJC*n)fz6x77OPjDzAAwaV*7)&(! zophrherf+K`Ph@JSbHF63BrGaJ_Y_uU|XyezWkJXBZ+{rVhVyx;6vx0c9Y)bHGTFh z=(gT7d=D~CaRF9b`ZMnciD7|Hd_^hjHwk2vGq=tFd6W}iz8hdrD+_fFC1glKpx7TY z97KAsuMcx=NUKM%QvFxY63A8o+|mn6H(euw+_SWceo9_E0r5ZlhSG(tvC2&E{zIjv zfmbq^pX8U^WWkTl4|d_%8cIUj+C6LkO+5YkM1T^@7hizE#&0dQ^AE#Wnsmw<0EwBq zAqEsE(}6#?Rd!n7tk@m&t*EYausQcgCi~`38~~*L9r#_7>HX6f*^e`?~xHMU}5fD$0k9qTp1kB=zMT zE|TF*>O@ok@g4k}<(^_=^nF*4)hkwjeSsd~uBwvk^qz?b|a?N~!P0zp>2f@EBy$N|V3u8B2PL zJNjRhbd}*^6WqE957*wY(BWcg;HmbY84*l>sxZg9?twYicRL3sME7=z)Y}eTTnEuC z+{a<_ICB^DTf4btP*pzN0oHDF%!Ve7^L@v+ zJx+0Rsb*3#pFXJ?KJa7hq1=a|5-Ah?tBn(N-caxVnHg$FO5ia|^D(m~6baBKg}Xju zi{PnSfD@Ts)>ozbx|t{b_rHULo3g*DyHlgTt9(*8o2@TK+yGyE+Tkip-EwehH{smE z#jelWV%V|YfR#J`x6-ezpQtfEs)I};2QCz_u=(>X5Qb#gowdD8=^lfL?;1 z_vUPyCBqM3Fn4tS_pJoG7IR1Dtf?e_dg~?7OGGonyYIDd51LXyWa>S~kxc`1CPnE50v5n9hx9)ntql|a{JDSzS0!tc2iiCgSWd0QpQQl`gwEC_A;B@1P?d!)oI?d z+3>ztc^y37a2emz1?o6uFyVYePTj&DnM_ zugJBYm}hDD1u61e-(Rb@$Kyuvogcn_!!9&wX7s z*-h5!ZIAg~H^CO;ruol`{AhpQbrWpOtQ74_c^zSM24IT**xOC8rOo96XJJ2>c=o$) zf-S}=pLq8p>jLUdd%6j>Hstmz)t(+aKn5uy`1V~l!Pfpt6f152jiQc=;8c^cn_!b) z|F;7dId@ LLM tasks are disabled by default to enable them add -> make pipelines-llm as task in [moviekg_docker.sh](../../scripts/moviekg_docker.sh) +Per-pipeline targets are also available (see `Makefile`), e.g.: -Prepare -``` -make setup_docker +```bash +make test-json-base +make test-rdf-base +make test-msp-all ``` -Execution of dataset stats, pipelines, evalaution, and paper content generation -``` +## Running pipelines (Docker workflow) + +This uses the `Makefile` targets to build images + start services and run pipelines inside Docker. + +```bash +cp docker_env docker.env +make setup_docker make run_docker_small ``` -For more detailed information see also [reproduce.md](../../docs/reproduce.md) or [docs](../../docs/) +> Note: LLM pipelines are typically disabled by default in Docker orchestration; enable them by adding the +> `pipelines-llm` step to the orchestration script used in your setup. -# Directory Structure +## Dataset overview (high level) -## Input Structure +- Dataset release: `https://doi.org/10.5281/zenodo.17246357` +- Sizes: `small` (100 films), `medium` (1k), `large` (10k) +- Formats per split: RDF, JSON, TEXT (incremental splits with seed/reference/source) + +## Directory structure + +### Input structure (example) ``` ├── film_100 @@ -84,12 +92,16 @@ For more detailed information see also [reproduce.md](../../docs/reproduce.md) o ├── film_1k[... trunc] ``` -## Output Structure +### Output structure (example) + +Pipeline outputs are written under `$OUTPUT_DIR/$DATASET_SELECT//stage_/` and include: +- `result.nt` (and optionally `result_eval.nt`) +- `exec-plan.json`, `exec-report.json` +- `tmp/` intermediate artifacts ``` ├── small -│   ├── all_metrics.csv -│   ├── json_a +│   ├── json_base │   │   ├── stage_1 │   │   │   ├── exec-plan.json │   │   │   ├── exec-report.json @@ -105,9 +117,6 @@ For more detailed information see also [reproduce.md](../../docs/reproduce.md) o │   │   ├── exec-report.json │   │   ├── result.nt │   │   └── tmp/ -│ ├── json_b[... trunc] -│   ├── paper -│   │   ├── test_fig....png -│   │   └── test_tab.....png +│ ├── json_alt[... trunc] └── medium[... trunc] ``` \ No newline at end of file diff --git a/experiments/moviekg/env b/experiments/moviekg/env index 7923c7e..92445d4 100644 --- a/experiments/moviekg/env +++ b/experiments/moviekg/env @@ -1,12 +1,12 @@ PIPELINE_CONFIG=pipeline.conf -DATASET_SELECT=medium +DATASET_SELECT=small -ONTOLOGY_PATH=/home/marvin/project/KGpipe/experiments/moviekg/movie-ontology.ttl -OUTPUT_DIR=/home/marvin/project/data/out/ +ONTOLOGY_PATH=./data/datasets/movie-ontology.ttl +OUTPUT_DIR=./data/results/ -DATASET_SMALL=/home/marvin/project/data/final/film_100 -DATASET_MEDIUM=/home/marvin/project/data/final/film_1k -DATASET_LARGE=/home/marvin/project/data/final/film_10k +DATASET_SMALL=./data/datasets/film_100 +DATASET_MEDIUM=./data/datasets/film_1k +DATASET_LARGE=./data/datasets/film_10k EMBEDDER=sentence-transformer DBPEDIA_ANNOTATE_URL='http://localhost:2222/rest/annotate' @@ -18,4 +18,3 @@ OLLAMA_TOKEN= OPENAI_TOKEN= LLM_ENDPOINT_URL= - diff --git a/experiments/moviekg/eval.sh b/experiments/moviekg/eval.sh deleted file mode 100644 index ecaad6f..0000000 --- a/experiments/moviekg/eval.sh +++ /dev/null @@ -1,10 +0,0 @@ -kgpipe eval -c metric_config.yaml \ - -m ReferenceTripleAlignmentMetricSoftEV \ - -m entity_count \ - -m incorrect_relation_direction \ - -m incorrect_relation_cardinality \ - -m incorrect_relation_range \ - -m incorrect_relation_domain \ - -m incorrect_datatype \ - -m incorrect_datatype_format \ - data/out/small/rdf_a/stage_3/result.nt diff --git a/experiments/moviekg/pipeline.conf b/experiments/moviekg/pipeline.conf index 53fe6fc..5c6964e 100644 --- a/experiments/moviekg/pipeline.conf +++ b/experiments/moviekg/pipeline.conf @@ -1,10 +1,6 @@ -# Pipeline Defintion +# Pipeline Defintions -# ======== -# RDF SSPs -# ======== - -rdf_a: +rdf_base: description: "Align source RDF with target KG" config: ENTITY_MATCHING_THRESHOLD: "0.99" @@ -16,23 +12,31 @@ rdf_a: - paris_exchange # 3 Fuse matched RDF (threshold 0.5) - fusion_first_value + # 4 Infer types / align to ontology - type_inference_ontology_simple -rdf_b: +rdf_alt: description: "Align source RDF with target KG with a tabular matching approach" config: ENTITY_MATCHING_THRESHOLD: "0.5" RELATION_MATCHING_THRESHOLD: "0.1" tasks: + # 1 Transform RDF to tabular representation - transform2_rdf_to_csv_v2 + # 2 Match entities (tabular) - pyjedai_entity_matching_v2 + # 3 Keep best match per entity - reduce_to_best_match_per_entity + # 4 Match relations/schemas (tabular) - valentine_csv_matching_v2 + # 5 Aggregate entity + relation matches - aggregate_2matches + # 6 Fuse matched RDF - fusion_first_value + # 7 Infer types / align to ontology - type_inference_ontology_simple -rdf_llm_schema_align_v1: +rdf_llm: description: "Align relations of source RDF with target KG using LLM" config: ENTITY_MATCHING_THRESHOLD: "0.99" @@ -42,96 +46,122 @@ rdf_llm_schema_align_v1: # 1 Use LLM to match relations - llm_task_rdf_ontology_matching_v1 # results in er.json # 2 Map source KG relations to matching target KG relations - # - map_kg_alignments - map_er_match_relations # 3 Match entities with paris - paris_entity_matching # 4 Exchange matched RDF - paris_exchange + # 5 Aggregate entity + relation matches - aggregate_2matches - # 5 Fuse matched RDF maybe only entities + # 6 Fuse matched RDF (maybe only entities) - fusion_first_value + # 7 Infer types / align to ontology - type_inference_ontology_simple -# ========= -# JSON SSPs -# ========= - -json_a: +json_base: description: "Construct intermediate RDF from JSON" tasks: # 1 Nested tree Json to generic RDF graph - construct_rdf_from_json3 # 2 Match RDF graph with seed - paris_entity_matching - # 3 exchange + # 3 Exchange matches - paris_exchange # 4 Fuse matched RDF (threshold 0.5) - fusion_first_value + # 5 Infer types / align to ontology - type_inference_ontology_simple -json_b: +json_alt: description: "Link JSON objects to target KG" tasks: - # construct TE_Document from JSON + # 1 Construct TE_Document from JSON - construct_linkedrdf_from_json_v3 # extract_json.py - # 4 Fuse matched RDF (threshold 0.5) + # 2 Select / fuse values - select_first_value + # 3 Infer types / align to ontology - type_inference_ontology_simple -json_llm_mapping_v1: +json_llm: description: "Align JSON path to target KG (ontology + sample KG)" tasks: + # 1 Use LLM to map JSON and construct intermediate RDF - llm_task_map_and_construct + # 2 Aggregate intermediate RDF outputs - aggregate_rdf_files + # 3 Match entities with paris - paris_entity_matching + # 4 Exchange matched RDF - paris_exchange + # 5 Fuse matched RDF - fusion_first_value + # 6 Infer types / align to ontology - type_inference_ontology_simple -# ========= -# Text SSPs -# ========= - -text_a: +text_base: description: "Use spoltight build RDF stagging Graph and apply Paris matching" tasks: + # 1 Extract triples with OpenIE - corenlp_openie_extraction + # 2 Convert extraction output to TE JSON - corenlp_exchange + # 3 Link relations (label+alias embedding) - label_alias_embedding_rl + # 4 Link entities with DBpedia Spotlight - dbpedia_spotlight_ner_nel + # 5 Convert Spotlight output to TE JSON - dbpedia_spotlight_exchange + # 6 Aggregate TE JSON artifacts - aggregate3_te_json + # 7 Construct RDF staging graph (mappings only) - construct_rdf_from_te_json_mappings_only + # 8 Match entities with paris - paris_entity_matching + # 9 Exchange matched RDF - paris_exchange + # 10 Fuse matched RDF - fusion_first_value + # 11 Infer types / align to ontology - type_inference_ontology_simple - -text_b: +text_alt: description: "(semi expensive) Use mini transformer to link entities and relations (label+alias)" tasks: + # 1 Extract triples with OpenIE - corenlp_openie_extraction # ("Berlin", "is a", "city") + # 2 Convert extraction output to TE JSON - corenlp_exchange + # 3 Link entities (label+alias embedding) - label_alias_embedding_el # ("Berlin" -> http://dbpedia.org/resource/Berlin) + # 4 Link relations (label+alias embedding) - label_alias_embedding_rl # ("is a" -> "rdf:type") + # 5 Aggregate TE JSON artifacts - aggregate3_te_json + # 6 Construct RDF staging graph - construct_rdf_from_te_json + # 7 Select / fuse values - select_first_value + # 8 Infer types / align to ontology - type_inference_ontology_simple -text_llm_triple_extract_v1: +text_llm: description: "Extract RDF from TEXT using LLM" config: LLM_MODEL: "gpt-5-mini" tasks: + # 1 Extract triples using LLM - llm_task_text_triple_extract_v1 + # 2 Link entities (label+alias embedding) - label_alias_embedding_el # ("Berlin" -> http://dbpedia.org/resource/Berlin) + # 3 Link relations (label+alias embedding) - label_alias_embedding_rl # ("is a" -> "rdf:type") + # 4 Aggregate TE JSON artifacts - aggregate3_te_json + # 5 Construct RDF staging graph - construct_rdf_from_te_json + # 6 Select / fuse values - select_first_value + # 7 Infer types / align to ontology - type_inference_ontology_simple # - type_inference_ontology_simple TODO why was this commented diff --git a/experiments/moviekg/src/moviekg/config.py b/experiments/moviekg/src/moviekg/config.py index 4460916..3727e00 100644 --- a/experiments/moviekg/src/moviekg/config.py +++ b/experiments/moviekg/src/moviekg/config.py @@ -43,22 +43,16 @@ pipeline_types = { - "rdf_a": "rdf", - "rdf_b": "rdf", - "text_a": "text", - "text_b": "text", - "json_a": "json", - "json_b": "json", + "rdf_base": "rdf", + "rdf_alt": "rdf", + "text_base": "text", + "text_alt": "text", + "json_base": "json", + "json_alt": "json", } llm_pipeline_types = { - "json_llm_mapping_v1": "json", - "rdf_llm_schema_align_v1": "rdf", - "text_llm_triple_extract_v1": "text", + "json_llm": "json", + "rdf_llm": "rdf", + "text_llm": "text", } - -ssp = { - "rdf": "rdf_a", - "json": "json_b", - "text": "text_a" -} \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/evaluation/__init__.py b/experiments/moviekg/src/moviekg/evaluation/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/experiments/moviekg/src/moviekg/evaluation/helpers.py b/experiments/moviekg/src/moviekg/evaluation/helpers.py deleted file mode 100644 index 4b80b58..0000000 --- a/experiments/moviekg/src/moviekg/evaluation/helpers.py +++ /dev/null @@ -1,206 +0,0 @@ -import json -import tempfile -import re -import shutil -from typing import List, Dict, Tuple -from pathlib import Path -from rdflib import Graph - -from kgpipe.common.models import KG, DataFormat -from kgpipe.evaluation.aspects import reference, semantic, statistical -from kgpipe.evaluation.aspects.reference import ReferenceConfig -from kgpipe.evaluation.base import MetricResult -from kgcore.api.ontology import OntologyUtil - -from moviekg.datasets.pipe_out import StageOut -from moviekg.config import dataset - -ontology_graph = Graph() -if dataset.ontology is None: - raise ValueError("No ontology found") -ontology_graph.parse(dataset.ontology.as_posix()) - -def show_ontology(): - if dataset.ontology is None: - raise ValueError("No ontology found") - ontology = OntologyUtil.load_ontology_from_file(dataset.ontology) - - for class_ in ontology.classes: - print(f"{class_.uri} {class_.label}") - # print(f"{class_.alias} {class_.description}") - print(f"{class_.equivalent}") - print(f"{class_.disjointWith}") - print("-" * 100) - - for property in ontology.properties: - print(f"{property.uri} {property.type} {property.label}") - # print(f"{property.alias} {property.description}") - print(f"{property.domain.uri} {property.range.uri} {property.equivalent}") - print(f"{property.min_cardinality} {property.max_cardinality}") - print("-" * 100) - -show_ontology() - - -def print_long_table_rows(rows: List[dict]): - """ - with correct margin and alignment - """ - max_aspect_length = max(len(row["aspect"]) for row in rows) - max_metric_name_length = max(len(row["metric"]) for row in rows) - max_value_length = max(len(str(row["value"])) for row in rows) - max_normalized_length = max(len(str(row["normalized"])) for row in rows) - max_duration_length = max(len(str(row["duration"])) for row in rows) - - print(f"{'Aspect':<{max_aspect_length}} | {'Metric':<{max_metric_name_length}} | {'Value':<{max_value_length}} | {'Normalized':<{max_normalized_length}} | {'Duration':<{max_duration_length}}") - print("-" * (max_aspect_length + max_metric_name_length + max_value_length + max_normalized_length + max_duration_length + 6)) - for row in rows: - print(f"{row['aspect']:<{max_aspect_length}} | {row['metric']:<{max_metric_name_length}} | {row['value']:<{max_value_length}} | {row['normalized']:<{max_normalized_length}} | {row['duration']:<{max_duration_length}}") - -def metrics_to_long_table_rows(metrics: List[MetricResult], pipeline_name: str, stage_name: str) -> List[dict]: - rows = [] - for metric in metrics: - rows.append({ - "pipeline": pipeline_name, - "stage": stage_name, - "aspect": metric.aspect.value, - "metric": metric.name, - "value": metric.value, - "normalized": metric.normalized_score, - "duration": metric.duration, - "details": json.dumps(metric.details, default=str) - }) - return rows - - - -def get_reference_config(stage: StageOut, is_ssp: bool) -> ReferenceConfig: - - # this is a pipeline name based hack to get the source type and source split id - def get_split_id_and_source_type(stage: StageOut, is_ssp: bool = False) -> Tuple[int, str]: - # stage_name is like "stage_1" - split_id = int(stage.stage_name.split("_")[1]) - pipeline_name = stage.root.parent.name - source_ord = pipeline_name.split("_") - - if len(source_ord) != 3 or is_ssp: - source_type = source_ord[0] - else: - source_type = source_ord[split_id-1] - return split_id, source_type - - split_id, source_type = get_split_id_and_source_type(stage) - - meta = dataset.splits[f"split_{split_id}"].sources[source_type].meta - verified_source_entities_path = dataset.splits[f"split_{split_id}"].kg_seed.root / "meta/verified_entities.csv" - verified_source_matches_path = meta.root / "verified_matches.csv" - - - kg_reference = dataset.splits[f"split_{split_id}"].kg_reference - if kg_reference is None: - raise ValueError(f"No reference KG found for split {split_id} and source type {source_type}") - reference_path = kg_reference.root / "data_agg.nt" - - kg_seed = dataset.splits[f"split_0"].kg_seed - if kg_seed is None: - raise ValueError(f"No seed KG found for split {0}") - seed_path = kg_seed.root / "data.nt" - - ENTITY_MATCH_THRESHOLD_MAP = { - "json_a": 0.99, - "rdf_a": 0.99, - "rdf_b": 0.5, - "rdf_c": 0.99, - "rdf_llm_schema_align_v1": 0.99 - } - - RELATION_MATCH_THRESHOLD_MAP = { - "json_a": 0.5, - "rdf_a": 0.5, - "rdf_b": 0.1, - "rdf_c": 0.5, - "rdf_llm_schema_align_v1": 0.5 - } - - return ReferenceConfig( - name="reference", - GT_MATCHES=verified_source_matches_path, - GT_MATCHES_TARGET_DATASET=dataset.splits[f"split_{0}"].root.name+"/kg/seed", - RELATION_MATCH_THRESHOLD=RELATION_MATCH_THRESHOLD_MAP.get(stage.root.parent.name, 0.5), - ENTITY_MATCH_THRESHOLD=ENTITY_MATCH_THRESHOLD_MAP.get(stage.root.parent.name, 0.99), - VERIFIED_SOURCE_ENTITIES=verified_source_entities_path, - REFERENCE_KG_PATH=reference_path, - SEED_KG_PATH=seed_path, - TE_LINK_THRESHOLD=0.5, - source_meta=meta, - dataset=dataset, - JSON_EXPECTED_DIR="/home/marvin/project/data/work/json", #TODO cleanup - JSON_EXPECTED_RELATION_FILE="/home/marvin/project/data/final/film_10k/split_0/sources/json/meta/verified_relation_matches.json" # TODO cleanup - ) - -from kgpipe.evaluation.base import MetricResult, EvaluationAspect - -def add_duration_metrics(stage: StageOut) -> MetricResult: - - try: - duration = stage.report.duration - return MetricResult( - metric=None, - kg=KG(id=f"result_{stage.stage_name}", name=f"result_{stage.stage_name}", path=stage.resultKG, format=DataFormat.RDF_NTRIPLES,plan=stage.plan), - aspect=EvaluationAspect.STATISTICAL, - name="duration", - value=duration, - normalized_score=0, - details={ - "duration": duration - } - ) - - except Exception as e: - return MetricResult( - metric=None, - kg=KG(id=f"result_{stage.stage_name}", name=f"result_{stage.stage_name}", path=stage.resultKG, format=DataFormat.RDF_NTRIPLES,plan=stage.plan), - aspect=EvaluationAspect.STATISTICAL, - name="duration", - value=0, - normalized_score=0, - details={"error": "No duration found"} - ) - - - -def evaluate_stage(stage: StageOut, is_ssp: bool) -> List[MetricResult]: - result_path = stage.resultKG - if result_path is None: - return [] - - result_kg = KG(id=f"result_{stage.stage_name}", name=f"result_{stage.stage_name}", path=result_path, format=DataFormat.RDF_NTRIPLES,plan=stage.plan) - - result_kg.set_ontology_graph(ontology_graph) - - stat_eval = statistical.StatisticalEvaluator() - ref_eval = reference.ReferenceEvaluator() - sem_eval = semantic.SemanticEvaluator() - - # stats_aspect_result = stat_eval.evaluate(result_kg) - ref_aspect_result = ref_eval.evaluate(result_kg, config=get_reference_config(stage, is_ssp), metrics=["SourceTypedEntityCoverageMetric"]) - # sem_aspect_result = sem_eval.evaluate(result_kg) - - metrics = [] - # metrics = stats_aspect_result.metrics + ref_aspect_result.metrics + sem_aspect_result.metrics - metrics = ref_aspect_result.metrics - # metrics = sem_aspect_result.metrics - metrics.append(add_duration_metrics(stage)) - # metrics = ref_aspect_result.metrics - - return metrics - -def replace_with_dict(infile: str, mapping: dict[str, str]) -> None: - with open(infile, encoding="utf-8") as f, \ - tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as tmp: - for line in f: - for key, val in mapping.items(): - line = re.sub(re.escape(key), val, line) - tmp.write(line) - tmp_path = tmp.name - shutil.move(tmp_path, infile) diff --git a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py b/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py deleted file mode 100644 index 61621cb..0000000 --- a/experiments/moviekg/src/moviekg/evaluation/test_eval_refactor.py +++ /dev/null @@ -1,263 +0,0 @@ -from kgpipe_eval.metrics import CountMetric, DuplicateMetric -from typing import List -from kgpipe_eval.api import MetricConfig, MetricResult -from kgpipe_eval.metrics.statistics import CountMetric -from kgpipe_eval.metrics.duplicates import DuplicateConfig, DuplicateMetric -from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric -from kgpipe_eval.metrics.triple_alignment import TripleAlignmentConfig, TripleAlignmentMetric -from kgpipe_eval.utils.alignment_utils import EntityAlignmentConfig -from kgpipe_eval.utils.kg_utils import KgLike, KgManager -from kgpipe_eval.evaluator import Evaluator -from pydantic import BaseModel, ConfigDict - -from kgpipe.datasets.multipart_multisource import Dataset, load_dataset -from kgpipe_eval.test.utils import render_metric_result -from pathlib import Path -import pytest -from kgpipe.common.model.pipeline import KgPipePlan, KgPipeReport -from kgpipe.common.model.kg import KG -from kgpipe.common.model.data import DataFormat -import json -from dataclasses import asdict -from itertools import permutations -from typing import Set -from kgpipe_eval.utils.kg_utils import Term - -try: - from moviekg import config as moviekg_config - from moviekg.pipelines.test_inc_msp import ssp, idfn -except Exception as e: - # These are integration-style tests that depend on local env/config files. - import traceback - traceback.print_exc() - pytest.skip(f"MovieKG config not available for eval integration test: {e}", allow_module_level=True) -# TODO -# [ ] Dataset Reader (split,ref,source,metadata) -# [ ] Pipeline Results Reader (stage,kg,plan,report,tmp_file) - - -# TODO clearify -# substract seed from kg_1 and kg_1 from kg_2, or only seed from kg_1 and kg_2 - - -EX_BENCH_DATA_PATH = Path("/home/marvin/phd/data/moviekg/datasets/film_10k") # TODO read from env -# EX_INC_PIPE_DATA_PATH = Path("/home/marvin/phd/data/moviekg/output/large/rdf_a") - -# TODO is a wrapper interface for now, Dataset needs refactor later -# TODO can be abstracted and implemented to have direct method per type, so dict is not needed for access -class KgBenchData(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True) - dataset: Dataset - - @staticmethod - def from_path(path: Path) -> 'KgBenchData': - dataset = load_dataset(path) - return KgBenchData(dataset=dataset) - - def get_verified_entities_path(self, i: int, source_type: str) -> Path: - current_path = self.dataset.splits[f"split_{i}"].kg_reference.meta.entities.file - current_new = current_path.with_name(f"{current_path.stem}_no_seed{current_path.suffix}") - return current_new - - def get_ignored_entities(self, i: int, source_type: str) -> Set[Term]: - seed_entities = self.dataset.splits[f"split_{0}"].kg_seed.meta.entities.read_csv() - # source_seed_entities = self.dataset.splits[f"split_{i-1}"].sources[source_type].meta.entities.read_csv() - return set([entity.entity_id for entity in seed_entities]) # + [entity.entity_id for entity in source_seed_entities]) - - -class KgPipeData(BaseModel): - model_config = ConfigDict(arbitrary_types_allowed=True) - result_kg: KgLike # name=rdf_a_1 - plan: KgPipePlan - report: KgPipeReport - tmp_dir: Path - - @staticmethod - def from_path(path: Path | str) -> 'KgPipeData': - path = Path(path) - plan = KgPipePlan.from_path(path / "exec-plan.json") - report = KgPipeReport.from_path(path / "exec-report.json") - tmp_dir = path / "tmp" - return KgPipeData( - result_kg=KG(name=path.name, id=path.name, path=path / "result_eval.nt", format=DataFormat.RDF_NTRIPLES), - plan=plan, - report=report, - tmp_dir=tmp_dir - ) - -def build_config_dict(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> dict[str, MetricConfig]: - dup_cfg = DuplicateConfig( - entity_alignment_config=EntityAlignmentConfig( - method="label_embedding", - verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data - verified_entities_delimiter="\t", - entity_sim_threshold=0.95, - ) - ) - - tri_cfg = TripleAlignmentConfig( - reference_kg=bench_data.dataset.splits[f"split_{i}"].kg_reference.root / "data_agg_eval.nt", - entity_alignment_config=EntityAlignmentConfig( - method="label_embedding", - reference_kg=bench_data.dataset.splits[f"split_{i}"].kg_reference.root / "data_agg_eval.nt", - # verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data - # verified_entities_delimiter="\t", - entity_sim_threshold=0.95, - ), - value_sim_threshold=0.5, - cache_literal_embeddings=True, - cache_ref_literal_embeddings=True, - ) - - ent_cfg = EntityAlignmentConfig( - method="label_embedding_and_intersecting_type", - verified_entities_path=bench_data.get_verified_entities_path(i=i, source_type="rdf"), # TODO type needs to be derived from pipe_data - verified_entities_delimiter="\t", - entity_sim_threshold=0.95, - ignored_entities=bench_data.get_ignored_entities(i=i, source_type="rdf") # TODO type needs to be derived from pipe_data - ) - - return { - "DuplicateMetric": dup_cfg, - "EntityAlignmentMetric": ent_cfg, - "TripleAlignmentMetric": tri_cfg, - } - - -def evaluate_stage(i: int, pipe_data: KgPipeData, bench_data: KgBenchData) -> List[MetricResult]: - tg = KgManager.load_kg(pipe_data.result_kg) - metrics = [ - CountMetric(), - EntityAlignmentMetric(), - DuplicateMetric(), - TripleAlignmentMetric(), - ] - config_dict = build_config_dict(i, pipe_data, bench_data) - return Evaluator().run(tg, metrics, config_dict) - - -def _stage_dirs(output_dir: Path) -> list[Path]: - stage_dirs = [p for p in output_dir.iterdir() if p.is_dir() and p.name.startswith("stage_")] - # stage_1, stage_2, ... - stage_dirs.sort(key=lambda p: int(p.name.split("_", 1)[1])) - return stage_dirs - - -def _metric_results_to_jsonable(results: list[MetricResult]) -> list[dict]: - """ - Convert `MetricResult` dataclasses to JSON-serializable dicts. - - `MetricResult.metric` is an object instance, so we store its key/classname. - """ - out: list[dict] = [] - for r in results: - metric_key = getattr(r.metric, "key", None) or r.metric.__class__.__name__ - out.append( - { - "metric": metric_key, - "summary": r.summary, - "measurements": [asdict(m) for m in r.measurements], - } - ) - return out - -# EX_PIPE_DATA_PATH = Path("/home/marvin/phd/data/moviekg/output/small/rdf_a/stage_1") -# def test_evaluate_stage(): -# if not EX_PIPE_DATA_PATH.exists() or not EX_BENCH_DATA_PATH.exists(): -# pytest.skip("Local MovieKG eval data not available; test is an integration/WIP scaffold.") -# pipe_data = KgPipeData.from_path(EX_PIPE_DATA_PATH) -# bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) -# results = evaluate_stage(1, pipe_data, bench_data) - -# # render -# print() # avoids pytest output being interleaved with print statements -# for result in results: -# print(render_metric_result(result, truncate=True, truncate_value=3)) - -# def test_evaluate_inc_stage(): -# if not EX_INC_PIPE_DATA_PATH.exists() or not EX_BENCH_DATA_PATH.exists(): -# pytest.skip("Local MovieKG inc data not available; test is an integration/WIP scaffold.") - -# for i in range(1, 4): -# pipe_data = KgPipeData.from_path(EX_INC_PIPE_DATA_PATH / f"stage_{i}") -# bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) -# results = evaluate_stage(i, pipe_data, bench_data) - -# # render -# print() # avoids pytest output being interleaved with print statements -# for result in results: -# print(render_metric_result(result, truncate=True, truncate_value=3)) - -@pytest.mark.parametrize( - "pipeline_name", - list[str](moviekg_config.pipeline_types.keys()) + list[str](moviekg_config.llm_pipeline_types.keys()), -) -def test_evaluate_new(pipeline_name: str): - """ - Boilerplate integration test that runs the new eval API for each pipeline - output under `OUTPUT_ROOT//stage_*`. - """ - output_dir = moviekg_config.OUTPUT_ROOT / pipeline_name - - if not output_dir.exists(): - pytest.skip(f"Pipeline output directory {output_dir} not found") - - stage_dirs = _stage_dirs(output_dir) - if not stage_dirs: - pytest.skip(f"No stage directories found under {output_dir}") - - # Uses the dataset selected/configured via `moviekg.config` env vars. - bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) - - for stage_dir in stage_dirs: - i = int(stage_dir.name.split("_", 1)[1]) - if i != 3: - continue # only run for stage 3 - pipe_data = KgPipeData.from_path(stage_dir) - results = evaluate_stage(i=i, pipe_data=pipe_data, bench_data=bench_data) - - eval_results = _metric_results_to_jsonable(results) - with open(stage_dir / "eval_results.json", "w") as f: - json.dump(eval_results, f, indent=2) - print(f"Wrote results to {stage_dir / 'eval_results.json'}") - - # Smoke checks: we got metric results back for this stage. - assert isinstance(results, list) - assert results - - -@pytest.mark.parametrize( - "source_1, source_2, source_3", - permutations(list[str](ssp.keys()), 3), - ids=idfn, -) -def test_evaluate_new_multisource_pipeline(source_1: str, source_2: str, source_3: str): - """ - Integration test for the *multi-source* incremental pipelines where the selected - source changes per iteration/stage (e.g. `a_b_c/stage_1`, `a_b_c/stage_2`, ...). - """ - pipeline_name = f"{source_1}_{source_2}_{source_3}" - output_dir = moviekg_config.OUTPUT_ROOT / pipeline_name - - if not output_dir.exists(): - pytest.skip(f"Pipeline output directory {output_dir} not found") - - stage_dirs = _stage_dirs(output_dir) - if not stage_dirs: - pytest.skip(f"No stage directories found under {output_dir}") - - bench_data = KgBenchData.from_path(EX_BENCH_DATA_PATH) - - for stage_dir in stage_dirs: - i = int(stage_dir.name.split("_", 1)[1]) - if i != 3: - continue # only run for stage 3 - pipe_data = KgPipeData.from_path(stage_dir) - results = evaluate_stage(i=i, pipe_data=pipe_data, bench_data=bench_data) - - eval_results = _metric_results_to_jsonable(results) - with open(stage_dir / "eval_results.json", "w") as f: - json.dump(eval_results, f, indent=2) - - assert isinstance(results, list) - assert results \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/evaluation/test_inc_msp_evaluation.py b/experiments/moviekg/src/moviekg/evaluation/test_inc_msp_evaluation.py deleted file mode 100644 index fa186d2..0000000 --- a/experiments/moviekg/src/moviekg/evaluation/test_inc_msp_evaluation.py +++ /dev/null @@ -1,61 +0,0 @@ -import pandas as pd -import pytest -import os -from typing import Sequence -from _pytest.compat import NotSetType -from itertools import permutations - -from moviekg.datasets.pipe_out import load_pipe_out -from moviekg.evaluation.helpers import evaluate_stage, metrics_to_long_table_rows -from moviekg.pipelines.test_inc_msp import ssp, idfn - -from moviekg.config import OUTPUT_ROOT - -@pytest.mark.parametrize( - "source_1, source_2, source_3", - permutations(list[str](ssp.keys()), 3), - ids=idfn -) -def test_inc_ssp_evaluation(source_1, source_2, source_3): - - output_dir = OUTPUT_ROOT / f"{source_1}_{source_2}_{source_3}" - - pipeline_name = f"{source_1}_{source_2}_{source_3}" - - print("-" * 100) - print(f"Evaluating {source_1}, {source_2}, {source_3}") - print("-" * 100) - - if not output_dir.exists(): - pytest.skip(f"Pipeline output directory {output_dir} not found") - - pipe_out = load_pipe_out(output_dir) - - rows = [] - - for stage in pipe_out.stages: - print("-" * 100) - print(f"{pipeline_name} - Stage: {stage.stage_name}") - print("-" * 100) - - metrics = evaluate_stage(stage, is_ssp=False) - rows.extend(metrics_to_long_table_rows(metrics, pipeline_name, stage.stage_name)) - # break # TODO remove - - metrics_df = pd.DataFrame(rows) - metrics_df.to_csv(OUTPUT_ROOT / f"{pipeline_name}_metrics.csv", index=False) - print("saved metrics to", OUTPUT_ROOT / f"{pipeline_name}_metrics.csv") - -def test_concatenate_long_table_rows(): - # glob - rows = [] - for file in OUTPUT_ROOT.glob("*_metrics.csv"): - if file.name == "all_metrics.csv": - continue - if os.path.getsize(file) < 3: - continue - df = pd.read_csv(file) - rows.extend(df.to_dict(orient="records")) - - metrics_df = pd.DataFrame(rows) - metrics_df.to_csv(OUTPUT_ROOT / "all_metrics.csv", index=False) diff --git a/experiments/moviekg/src/moviekg/evaluation/test_inc_ssp_evaluation.py b/experiments/moviekg/src/moviekg/evaluation/test_inc_ssp_evaluation.py deleted file mode 100644 index 2e62f54..0000000 --- a/experiments/moviekg/src/moviekg/evaluation/test_inc_ssp_evaluation.py +++ /dev/null @@ -1,58 +0,0 @@ -import pytest -import pandas as pd -import os -from pathlib import Path - -from moviekg.datasets.pipe_out import load_pipe_out -from moviekg.evaluation.helpers import evaluate_stage, metrics_to_long_table_rows, print_long_table_rows -from moviekg.pipelines.test_inc_ssp import pipeline_types, llm_pipeline_types - -from moviekg.config import OUTPUT_ROOT - -@pytest.mark.parametrize( - "pipeline_name", - list[str](pipeline_types.keys()) + list[str](llm_pipeline_types.keys()) -) -def test_inc_ssp_evaluation(pipeline_name): - - output_dir = OUTPUT_ROOT / pipeline_name - - print("-" * 100) - print(f"Evaluating {pipeline_name}") - print("-" * 100) - - if not output_dir.exists(): - pytest.skip(f"Pipeline output directory {output_dir} not found") - - pipe_out = load_pipe_out(output_dir) - - rows = [] - - for stage in pipe_out.stages: - print("-" * 100) - print(f"{pipeline_name} - Stage: {stage.stage_name}") - print("-" * 100) - - metrics = evaluate_stage(stage, is_ssp=True) - new_rows = metrics_to_long_table_rows(metrics, pipeline_name, stage.stage_name) - print_long_table_rows(new_rows) - rows.extend(new_rows) - # break # TODO remove this - - metrics_df = pd.DataFrame(rows) - metrics_df.to_csv(OUTPUT_ROOT / f"{pipeline_name}_metrics.csv", index=False) - print("saved metrics to", OUTPUT_ROOT / f"{pipeline_name}_metrics.csv") - -def test_concatenate_long_table_rows(): - # glob - rows = [] - for file in OUTPUT_ROOT.glob("*_metrics.csv"): - if file.name == "all_metrics.csv": - continue - if os.path.getsize(file) < 3: - continue - df = pd.read_csv(file) - rows.extend(df.to_dict(orient="records")) - - metrics_df = pd.DataFrame(rows) - metrics_df.to_csv(OUTPUT_ROOT / "all_metrics.csv", index=False) diff --git a/experiments/moviekg/src/moviekg/evaluation/test_ref_dev.py b/experiments/moviekg/src/moviekg/evaluation/test_ref_dev.py deleted file mode 100644 index 1a4d17b..0000000 --- a/experiments/moviekg/src/moviekg/evaluation/test_ref_dev.py +++ /dev/null @@ -1,247 +0,0 @@ -# from pathlib import Path -# import numpy as np -# from sentence_transformers import SentenceTransformer -# from rdflib import Graph, URIRef, Literal, RDF, RDFS, XSD -# import re -# from tqdm import tqdm - -# def integrated_entities(path_actual_kg, path_expected_kg): -# pass - -# SOFT_ENTITY_THRESHOLD = 0.75 -# SOFT_VALUES_THRESHOLD = 0.75 - -# def encode(values, model, desc: str): -# embeddings = [] -# for i in tqdm(range(0, len(values), 64), desc=desc): -# batch = values[i:i+64] -# batch_emb = model.encode(batch, show_progress_bar=False) -# embeddings.append(batch_emb) -# return np.vstack(embeddings) - -# def graph_fact_alginment(ga: Graph, ge: Graph): -# te = [ str(s)+str(p)+str(o) for s, p, o in ge ] -# ta = [ str(s)+str(p)+str(o) for s, p, o in ga ] - -# tp = len(set(ta) & set(te)) -# fp = len(set(ta) - set(te)) -# fn = len(set(te) - set(ta)) - -# print(f"TP: {tp}, FP: {fp}, FN: {fn}") -# print(f"Precision: {tp / (tp + fp)}") -# print(f"Recall: {tp / (tp + fn)}") -# print(f"F1: {2 * tp / (2 * tp + fp + fn)}") - -# def clean_label(label: str): -# # remove all non-alphanumeric characters -# cleaned_label = label.replace("_", " ") -# # remove parenthesis text -# cleaned_label = re.sub(r'\([^)]*\)', '', cleaned_label) -# return cleaned_label.strip() - - -# def graph_match_labels_soft(ga: Graph, ge: Graph, model: SentenceTransformer): -# actual_uri_to_abels = {} -# expected_uri_to_abels = {} - -# for s, _, o in ga.triples((None, RDFS.label, None)): -# actual_uri_to_abels[str(s)] = clean_label(str(o)) - -# for s, _, o in ge.triples((None, RDFS.label, None)): -# expected_uri_to_abels[str(s)]= clean_label(str(o)) - -# actual_embeddings = encode(list(actual_uri_to_abels.values()), model, "Encoding actual labels") -# expected_embeddings = encode(list(expected_uri_to_abels.values()), model, "Encoding expected labels") - -# cosine_scores = np.dot(actual_embeddings, expected_embeddings.T) - -# actual_uri_keys = list(actual_uri_to_abels.keys()) -# expected_uri_keys = list(expected_uri_to_abels.keys()) - -# # get best match expected uri for each actual uri - -# uri_mappings = {} - -# best_matches = [] -# for i in range(len(actual_uri_keys)): -# best_match = expected_uri_keys[np.argmax(cosine_scores[i])] -# best_score = cosine_scores[i][np.argmax(cosine_scores[i])] -# best_matches.append((best_match, best_score)) - -# for i in range(len(best_matches)): -# if best_matches[i][1] > SOFT_ENTITY_THRESHOLD: -# # la = actual_uri_to_abels[actual_uri_keys[i]].replace(" ", "_") -# # le = expected_uri_to_abels[best_matches[i][0]].replace(" ", "_") -# uri_actual = actual_uri_keys[i] -# uri_expected = best_matches[i][0] -# uri_mappings[uri_actual] = uri_expected - -# return uri_mappings - -# def graph_fact_alginment_soft_entities(ga: Graph, ge: Graph, model: SentenceTransformer): -# uri_mappings = graph_match_labels_soft(ga, ge, model) - -# ga_mapped = Graph() -# for s, p, o in ga: -# if str(s) in uri_mappings: -# s = URIRef(uri_mappings[str(s)]) -# if isinstance(o, URIRef) and str(o) in uri_mappings: -# o = URIRef(uri_mappings[str(o)]) -# ga_mapped.add((s, p, o)) - -# graph_fact_alginment(ga_mapped, ge) - -# # TODO rdf:type is removed for tp calculation -# def graph_fact_alginment_soft_entities_values(ga: Graph, ge: Graph, model: SentenceTransformer): -# uri_mappings = graph_match_labels_soft(ga, ge, model) - -# def get_label(o: URIRef, graph: Graph): -# labels = [str(l) for l in graph.objects(o, RDFS.label)] -# if len(labels) == 0: -# return [] -# else: -# return [clean_label(l) for l in labels] - -# ga_mapped = Graph() -# for s, p, o in ga: -# if str(s) in uri_mappings: -# s = URIRef(uri_mappings[str(s)]) -# if isinstance(o, URIRef): # and p != RDF.type -# for label in get_label(o, ga): -# ga_mapped.add((s, p, Literal(label))) -# else: -# ga_mapped.add((s, p, o)) - -# ge_mapped = Graph() -# for s, p, o in ge: -# if isinstance(o, URIRef): # and p != RDF.type -# for label in get_label(o, ge): -# ge_mapped.add((s, p, Literal(label))) -# else: -# ge_mapped.add((s, p, o)) - -# # encode all values -# vas = list(set([str(o) for _, _, o in ga_mapped if not isinstance(o, URIRef)])) -# ves = list(set([str(o) for _, _, o in ge_mapped if not isinstance(o, URIRef)])) - -# va_embeddings = encode(vas, model, "Encoding actual values") -# ve_embeddings = encode(ves, model, "Encoding expected values") - -# v2e_actual = {} -# v2e_expected = {} - -# for idx, v in enumerate(vas): -# v2e_actual[v] = va_embeddings[idx] - -# for idx, v in enumerate(ves): -# v2e_expected[v] = ve_embeddings[idx] - -# tp = 0 -# fp = 0 -# fn = 0 - -# sp_actual = set() - -# # for each (s, p, o) in ga_mapped check if there is a matching value for the same (s, p) in ge -# for s, p in ga_mapped.subject_predicates(unique=True): -# sp_actual.add((s, p)) -# _vas = [str(o) for o in ga_mapped.objects(s, p)] -# _ves = [str(o) for o in ge_mapped.objects(s, p)] -# _vas_embeddings = np.array([v2e_actual[v] for v in _vas]) -# _ves_embeddings = np.array([v2e_expected[v] for v in _ves]) - -# if len(_vas_embeddings) == 0 or len(_ves_embeddings) == 0: -# continue -# cosine_scores = np.dot(_vas_embeddings, _ves_embeddings.T) # (len(_vas_embeddings), len(_ves_embeddings)) - -# for idx in range(len(_vas)): -# best_match = _ves[np.argmax(cosine_scores[idx])] -# best_score = cosine_scores[idx][np.argmax(cosine_scores[idx])] -# if best_score > SOFT_VALUES_THRESHOLD: -# actual_value = _vas[idx] -# reference_value = best_match -# tp += 1 -# # if actual_value == reference_value: -# # # print(f"Found matching value for {s} {p} {actual_value}") -# # pass -# # else: -# # print(f"Found matching value for {s} {p} {actual_value} but not exact reference {reference_value}") -# # print(f"Value actual: {_vas[idx]}, {best_match}, {best_score}") -# # print(f"Value expected: {_ves[np.argmax(cosine_scores[idx])]}") -# else: -# fp += 1 -# # print(f"No matching value for {s} {p} {_vas[idx]} from references {_ves}") - -# sp_expected = set([(s, p) for s, p in ge_mapped.subject_predicates(unique=True)]) -# missing_sp = sp_expected - sp_actual -# for s, p in missing_sp: -# for _ in ge_mapped.triples((s, p, None)): -# fn += 1 - -# print(f"TP: {tp}, FP: {fp}, FN: {fn}") -# print(f"Precision: {tp / (tp + fp)}") -# print(f"Recall: {tp / (tp + fn)}") -# print(f"F1: {2 * tp / (2 * tp + fp + fn)}") - -# def reference_alignment(path_actual_kg: Path, path_expected_kg: Path): -# ga = Graph() -# ga.parse(path_actual_kg) - -# ge = Graph() -# ge.parse(path_expected_kg) - -# graph_fact_alginment(ga, ge) - -# def reference_alignment_soft_entities(path_actual_kg: Path, path_expected_kg: Path): - -# model = SentenceTransformer("all-MiniLM-L6-v2") -# model.to("cuda") - -# ga = Graph() -# ga.parse(path_actual_kg) - -# ge = Graph() -# ge.parse(path_expected_kg) - -# graph_fact_alginment_soft_entities(ga, ge, model) - -# def reference_alignment_soft_entities_values(path_actual_kg: Path, path_expected_kg: Path): - -# model = SentenceTransformer("all-MiniLM-L6-v2") -# model.to("cuda") - -# ga = Graph() -# ga.parse(path_actual_kg) - -# ge = Graph() -# ge.parse(path_expected_kg) - -# graph_fact_alginment_soft_entities_values(ga, ge, model) - -# def test_integrated_verified_source_entities(): -# print("Integrated verified source entities") -# path_actual_kg = Path("/home/marvin/project/code/experiments/out_film_100/rdf_a/stage_1/result.nt") -# path_expected_kg = Path("/home/marvin/project/data/final/film_100/split_3/kg/reference/data_agg.nt") -# integrated_entities(path_actual_kg, path_expected_kg) - -# def test_reference_alignment(): -# print("Reference alignment") -# path_actual_kg = Path("/home/marvin/project/code/experiments/out_film_100/rdf_a/stage_1/result.nt") -# path_expected_kg = Path("/home/marvin/project/data/final/film_100/split_3/kg/reference/data_agg.nt") -# reference_alignment(path_actual_kg, path_expected_kg) - -# def test_reference_alignment_soft(): -# print("Reference alignment soft") -# path_actual_kg = Path("/home/marvin/project/code/experiments/out_film_100/text_b/stage_1/result.nt") -# path_expected_kg = Path("/home/marvin/project/data/final/film_100/split_3/kg/reference/data_agg.nt") -# reference_alignment_soft_entities(path_actual_kg, path_expected_kg) - -# def test_reference_alignment_soft_entities_values(): -# print("Reference alignment soft entities values") -# path_actual_kg = Path("/home/marvin/project/code/experiments/out_film_100/rdf_a/stage_1/result.nt") -# path_expected_kg = Path("/home/marvin/project/data/final/film_100/split_3/kg/reference/data_agg.nt") -# reference_alignment_soft_entities_values(path_actual_kg, path_expected_kg) - -# if __name__ == "__main__": -# test_integrated_verified_source_entities() -# test_reference_alignment() \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/evaluation/test_sensitivity.py b/experiments/moviekg/src/moviekg/evaluation/test_sensitivity.py deleted file mode 100644 index 0c68210..0000000 --- a/experiments/moviekg/src/moviekg/evaluation/test_sensitivity.py +++ /dev/null @@ -1,159 +0,0 @@ -from dataclasses import dataclass -from typing import List -from kgpipe.common import KgPipe, Data, DataFormat, KG -from pathlib import Path -from kgpipe.common.models import KgPipePlan -from kgpipe.evaluation.aspects.reference import ( - ReferenceEvaluator, ReferenceConfig, - ER_EntityMatchMetric, ER_RelationMatchMetric, - TE_ExpectedEntityLinkMetric, TE_ExpectedRelationLinkMetric -) -import os -@dataclass -class BinaryClassifier: - tp: int - fp: int - tn: int - fn: int - - def accuracy(self) -> float: - return (self.tp + self.tn) / (self.tp + self.tn + self.fp + self.fn) - - def precision(self) -> float: - return self.tp / (self.tp + self.fp) - -@dataclass -class ThresholdSensitivityResult: - pipeline_name: str - threshold: float - result: BinaryClassifier - -benchdata = Path("/home/marvin/phd/kgpipe/experiments/moviekg/data/datasets/film_10k/") -seed_path = benchdata / "split_0/kg/seed/data.nt" -rdf_path = benchdata / "split_1/sources/rdf/data.nt" -result_dir_path = Path(f"data/moviekg/threshold_sensitivity/") - -# reference_evaluator = ReferenceEvaluator() - -def run_paris_pipeline(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - from kgpipe_tasks.tasks import paris_entity_matching, paris_exchange - - pipe_result_dir_path = result_dir_path / f"{pipeline_name}" - pipeline = KgPipe( - name="paris pipeline", - tasks=[paris_entity_matching, paris_exchange], - seed=Data(path=seed_path, format=DataFormat.RDF_NTRIPLES), - data_dir=pipe_result_dir_path / "tmp" - ) - plan = pipeline.build( - source=Data(path=rdf_path, format=DataFormat.RDF_NTRIPLES), - result=Data(path=pipe_result_dir_path / "result.json", format=DataFormat.ER_JSON) - ) - - os.makedirs(pipe_result_dir_path, exist_ok=True) - - with open(pipe_result_dir_path / "exec-plan.json", "w") as f: - f.write(plan.model_dump_json(indent=4)) - - pipeline.run() - -def paris_er_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - config = ReferenceConfig( - name="paris config", - ENTITY_MATCH_THRESHOLD=threshold, - RELATION_MATCH_THRESHOLD=threshold, - GT_MATCHES=benchdata / "split_1/sources/rdf/meta/verified_matches.csv", - GT_MATCHES_TARGET_DATASET="split_0/kg/seed" - ) - - plan = KgPipePlan.model_validate_json(open(result_dir_path / f"{pipeline_name}" / "exec-plan.json").read()) - - kg = KG(id="paris", name="paris", path=Path(f"data/moviekg/paris/{pipeline_name}.nt"), format=DataFormat.RDF_NTRIPLES, plan=plan) - - metric_result = ER_EntityMatchMetric().compute(kg, config=config) - # print(metric_result) - - return metric_result - -def paris_om_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - config = ReferenceConfig( - name="paris config", - ENTITY_MATCH_THRESHOLD=threshold, - RELATION_MATCH_THRESHOLD=threshold, - GT_MATCHES=benchdata / "split_1/sources/rdf/meta/verified_matches.csv", - GT_MATCHES_TARGET_DATASET="split_0/kg/seed" - ) - - plan = KgPipePlan.model_validate_json(open(result_dir_path / f"{pipeline_name}" / "exec-plan.json").read()) - - kg = KG(id="paris", name="paris", path=Path(f"data/moviekg/paris/{pipeline_name}.nt"), format=DataFormat.RDF_NTRIPLES, plan=plan) - - metric_result = ER_RelationMatchMetric().compute(kg, config=config) - # print(metric_result) - - return metric_result - -def test_paris(): - # run_paris_pipeline("paris", 0.99) - range_of_thresholds = [0.0, 0.001, 0.01, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99, 0.999, 1.0] - - er_results = [] - for threshold in range_of_thresholds: - result = paris_er_threshold_sensitivity("paris", threshold) - er_results.append([threshold, result.normalized_score, result.details]) - - print() - print("ER Results:") - for r in er_results: - print(r[0], r[1], r[2]) - - om_results = [] - for threshold in range_of_thresholds: - result = paris_om_threshold_sensitivity("paris", threshold) - om_results.append([threshold, result.normalized_score, result.details]) - - print("OM Results:") - for r in om_results: - print(r[0], r[1], r[2]) - -# def paris_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: -# result = run_paris_pipeline(pipeline_name, threshold) - -# pipeline.run( -# input=[Data(path=Path(f"data/moviekg/paris/{pipeline_name}.nt"), format=DataFormat.RDF_NTRIPLES)], -# output=[Data(path=Path(f"data/moviekg/paris/{pipeline_name}.paris_csv"), format=DataFormat.PARIS_CSV)] -# ) - -# config = ReferenceConfig( -# name="paris config", -# ENTITY_MATCH_THRESHOLD=threshold, -# RELATION_MATCH_THRESHOLD=threshold -# ) - - - - -# # TODO get config from dataset -# # kg = KG(path=Path(f"data/moviekg/paris/{pipeline_name}.nt")) -# # reference_kg = KG(path=Path("data/moviekg/paris/reference.nt")) -# # result = reference_evaluator.evaluate(kg, reference_kg) -# # return result -# pass - -def jedai_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - pass - -def valentine_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - pass - -def corenlp_openie_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - pass - -def dbpedia_spotlight_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - pass - -def custom_relation_linking_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - pass - -def custom_entity_linking_threshold_sensitivity(pipeline_name: str, threshold: float) -> List[ThresholdSensitivityResult]: - pass \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/paper/__init__.py b/experiments/moviekg/src/moviekg/paper/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/experiments/moviekg/src/moviekg/paper/config.py b/experiments/moviekg/src/moviekg/paper/config.py deleted file mode 100644 index 507a5c0..0000000 --- a/experiments/moviekg/src/moviekg/paper/config.py +++ /dev/null @@ -1,135 +0,0 @@ - -HEADERS = ["pipeline", "stage", "aspect", "metric", "value", "normalized", "duration", "details"] - -# Only keep these classes and aggregate the rest into "Other" -main_classes = [ - "http://kg.org/ontology/Company", - "http://kg.org/ontology/Person", - "http://kg.org/ontology/Film" -] - -name_mapping = { - "rdf_a": r"\sspRDFa", - "rdf_b": r"\sspRDFb", - "rdf_c": r"\sspRDFc", - "rdf_llm_schema_align_v1": r"\sspRDFc", - "json_a": r"\sspJSONa", - "json_b": r"\sspJSONb", - "json_baseA": r"\sspJSONbaseA", - "json_c": r"\sspJSONc", - "json_llm_mapping_v1": r"\sspJSONc", - "text_a": r"\sspTexta", - "text_b": r"\sspTextb", - "text_c": r"\sspTextc", - "text_llm_triple_extract_v1": r"\sspTextc", - "rdf_json_text": r"\mspRJT", - "rdf_text_json": r"\mspRTJ", - "json_rdf_text": r"\mspJRT", - "json_text_rdf": r"\mspJTR", - "text_rdf_json": r"\mspTRJ", - "text_json_rdf": r"\mspTJR", -} - -METRIC_NAME_MAP = { - "entity_count": "EC", - "relation_count": "RC", - "triple_count": "FC", - "class_count": "TC", - "duration": "Time", - "loose_entity_count": "LEC", - "shallow_entity_count": "SEC", - # Semantic/Reasoning metrics - "reasoning": "EO", - "disjoint_domain": "EO1", - "incorrect_relation_direction": "EO2", - "incorrect_relation_cardinality": "EO3", - "incorrect_relation_range": "EO4", - "incorrect_relation_domain": "EO5", - "incorrect_datatype": "EO6", - "incorrect_datatype_format": "EO7", - "ontology_class_coverage": "EO8", - "ontology_relation_coverage": "EO9", - "ontology_namespace_coverage": "E10", - # Reference metrics - "ReferenceTripleAlignmentMetric": "RTC", - "ReferenceTripleAlignmentMetricSoftE": "RTC-SoftE", - "ReferenceTripleAlignmentMetricSoftEV": "RTC-SoftEV", - "ReferenceClassCoverageMetric": "RCC", - # ER metrics - "ER_EntityMatchMetric": "ER-EM", - "ER_RelationMatchMetric": "ER-RM", - # TE metrics - "TE_ExpectedEntityLinkMetric": "TE-EEL", - "TE_ExpectedRelationLinkMetric": "TE-ERL", - # Source metrics - "SourceEntityCoverageMetric": "VSEC", - "SourceEntityCoverageMetricSoft": "VSEC-Soft", - "REI_precision": "REI-Precision", - -} - -# long: -# disjoint_domain -# incorrect_relation_domain -# incorrect_relation_range -# incorrect_relation_direction -# incorrect_datatype -# incorrect_datatype_format -# short:ODT OD OR ORD OLT OLF OAvg -SEM_METRIC_SHORT_NAMES = { - # "reasoning" : "EO0", - "disjoint_domain": "$O_{DT}$", - "incorrect_relation_direction": "$O_{RD}$", - "incorrect_relation_cardinality": "$O_{CA}$", - "incorrect_relation_range": "$O_{R}$", - "incorrect_relation_domain": "$O_{D}$", - "incorrect_datatype": "$O_{LT}$", - "incorrect_datatype_format": "$O_{LF}$", - # "ontology_class_coverage": "$O_{CC}$", - # "ontology_relation_coverage": "$O_{RC}$", - # "ontology_namespace_coverage": "$O_{NC}$", -} - -METRIC_NAME_INDEX_PRETTY = [ - ("duration", "Runtime Duration"), - ("triple_count", "Fact/Triple Count"), - ("entity_count", "Entity Count"), - ("relation_count", "Relation Count"), - ("class_count", "Entity Type Count"), - ("Person", "Persons"), - ("Film", "Films"), - ("Company", "Companies"), - # ("Other", "Other Type"), - ("loose_entity_count", "Empty Entities"), - ("shallow_entity_count", "Shallow Entities"), - # Semantic/Reasoning metrics - # ("reasoning", "Reasoning"), - ("disjoint_domain", "Disjoint Domain"), - ("incorrect_relation_direction", "Incorrect Relation Direction"), - ("incorrect_relation_cardinality", "Incorrect Relation Cardinality"), - ("incorrect_relation_range", "Incorrect Relation Range"), - ("incorrect_relation_domain", "Incorrect Relation Domain"), - ("incorrect_datatype", "Incorrect Datatype"), - ("incorrect_datatype_format", "Incorrect Datatype Format"), - # ("ontology_class_coverage", "Ontology Class Coverage"), - # ("ontology_relation_coverage", "Ontology Relation Coverage"), - # ("ontology_namespace_coverage", "Ontology Namespace Coverage"), - # Source metrics - ("SourceEntityCoverageMetric", "Source Entity Recall"), - ("SourceEntityCoverageMetricSoft", "Source Entity Recall (~ID)"), - ("REI_precision", "Source Entity Precision (~ID)"), - # Reference metrics - ("ReferenceTripleAlignmentMetric", "Reference Alignment (f1)"), - ("ReferenceTripleAlignmentMetricSoftE", "Reference Alignment (~ID) (f1)"), - ("ReferenceTripleAlignmentMetricSoftEV", "Reference Alignment (~ID~Value) (f1)"), - # ("ReferenceClassCoverageMetric", "Reference Class Coverage"), - # ER metrics - ("ER_EntityMatchMetric", "Entity Match (p)"), - ("ER_RelationMatchMetric", "Relation Match (p)"), - # TE metrics - ("TE_ExpectedEntityLinkMetric", "Expected Entity Link (p)"), - ("TE_ExpectedRelationLinkMetric", "Expected Relation Link (p)"), -] - -METRIC_NAME_MAP_PRETTY = {k: v for k, v in METRIC_NAME_INDEX_PRETTY} -SEM_METRIC_LONG_NAMES = {v: k for k, v in SEM_METRIC_SHORT_NAMES.items()} diff --git a/experiments/moviekg/src/moviekg/paper/helpers/__init__.py b/experiments/moviekg/src/moviekg/paper/helpers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/experiments/moviekg/src/moviekg/paper/helpers/agggregate.py b/experiments/moviekg/src/moviekg/paper/helpers/agggregate.py deleted file mode 100644 index adff60e..0000000 --- a/experiments/moviekg/src/moviekg/paper/helpers/agggregate.py +++ /dev/null @@ -1,224 +0,0 @@ -import pandas as pd -import json -import numpy as np -from moviekg.paper.config import SEM_METRIC_SHORT_NAMES -from moviekg.paper.helpers.helpers import load_metrics_from_file -from moviekg.config import OUTPUT_ROOT - -def agg_duration_over_stages_per_pipeline(metric_df): - # group by pipeline and stage and take mean of normalized - metric_df = metric_df[metric_df["metric"] == "duration"] - # print(metric_df) - metric_df = metric_df.groupby(["pipeline"])["value"].sum().reset_index() - # add stage column = stage 3 - metric_df["stage"] = "stage_3"# - metric_df["metric"] = "duration" - - # print(metric_df.to_string()) - return metric_df - -def norm_min(min, value): - return (min/value) - -def norm_max(max, value): - return 1 / (max/value) - -def get_average_f1_source_entity_f1(df: pd.DataFrame): - df = df[df["metric"] == "SourceEntityPrecisionMetric"] - - for row in df.itertuples(): - details = json.loads(row.details) - expected_entities_count = details["expected_entities_count"] - found_entities_count = details["found_entities_count"] - overlapping_entities_count = details["overlapping_entities_count"] - possible_duplicates_count = details["possible_duplicates_count"] - overlapping_entities_strict_count = details["overlapping_entities_strict_count"] - - # print(f"pipeline={row.pipeline}, stage={row.stage}, expected_entities_count={expected_entities_count}, found_entities_count={found_entities_count}, overlapping_entities_count={overlapping_entities_count}, possible_duplicates_count={possible_duplicates_count}, overlapping_entities_strict_count={overlapping_entities_strict_count}") - precision = overlapping_entities_strict_count / overlapping_entities_count - precision = precision if precision <= 1.0 else 1.0 - recall = overlapping_entities_count / expected_entities_count - recall = recall if recall <= 1.0 else 1.0 - f1 = 2 * (precision * recall) / (precision + recall) - df.loc[row.Index, "normalized"] = f1 - - df = df[["pipeline", "normalized"]] - - # save as csv - - # calculate the average of the metrics - df = df.groupby("pipeline").mean().reset_index() - # set as value for normalized and stage_3 - df["stage"] = "stage_3" - df["metric"] = "SourceEntityF1Metric" - df["value"] = df["normalized"] - df = df[["pipeline", "stage", "metric", "value"]] - - return df - -def aggregate_reference_metrics(df: pd.DataFrame): - metrics = [ - "ReferenceTripleAlignmentMetricSoftEV", - "SourceEntityPrecisionMetric", - ] - - source_entity_f1_df = get_average_f1_source_entity_f1(df) - df = pd.concat([df, source_entity_f1_df]) - - df = df[df["metric"].isin(metrics)] - # if metric is ReferenceTripleAlignmentMetricSoftEV get details["f1"] and set normalized to it - - df.loc[df["metric"] == "ReferenceTripleAlignmentMetricSoftEV", "normalized"] = df[df["metric"] == "ReferenceTripleAlignmentMetricSoftEV"]["details"].apply(lambda x: json.loads(x)["f1_score"]) - - df = df[["pipeline", "stage", "metric", "normalized"]] - - new_rows = [] - for pipeline in df["pipeline"].unique(): - new_rows.append({ - "pipeline": pipeline, - "stage": "stage_3", - "metric": "EntityMatchingMetric", - "normalized": 0.85 - }) - new_rows.append({ - "pipeline": pipeline, - "stage": "stage_3", - "metric": "OntologyMatchingMetric", - "normalized": 0.75 - }) - new_rows.append({ - "pipeline": pipeline, - "stage": "stage_3", - "metric": "EntityLinkingMetric", - "normalized": 0.44 - }) - - df = pd.concat([df, pd.DataFrame(new_rows)]) - - - # for each pipeline and stage = stage_3, calculate the average of the metrics - df = df[df["stage"] == "stage_3"] - - return df - -def aggregate_efficiency_metrics(df: pd.DataFrame): - metrics = ["duration", "memory_peak"] - df = df[df["metric"].isin(metrics)] - df = df[["pipeline", "stage", "metric", "value"]] - # for duration aggregate sum the values for each pipeline and stage - - duration_df = agg_duration_over_stages_per_pipeline(df) - # remove duration - df = df[df["metric"] != "duration"] - df = pd.concat([df, duration_df]) - - df["stage"] = "stage_3" - - def get_min_for_metric(metric): - return df[df["metric"] == metric]["value"].min() - - def get_max_for_metric(metric): - return df[df["metric"] == metric]["value"].max() - - for metric in df["metric"].unique(): - min_val = get_min_for_metric(metric) - max_val = get_max_for_metric(metric) - df.loc[df["metric"] == metric, "normalized"] = norm_min(min_val, df["value"]) - - return df - - -def aggregate_semantic_metrics(df: pd.DataFrame): - metrics = list(SEM_METRIC_SHORT_NAMES.keys()) - df = df[df["metric"].isin(metrics)] - df = df[["pipeline", "stage", "metric", "normalized"]] - # for each pipeline and stage = stage_3, calculate the average of the metrics - df = df[df["stage"] == "stage_3"] - - return df - -def aggregate_size_metrics(df: pd.DataFrame): - metrics = ["entity_count", "triple_count"] - df = df[df["metric"].isin(metrics)] - df = df[["pipeline", "stage", "metric", "value"]] - # for each pipeline and stage = stage_3, calculate the average of the metrics - df = df[df["stage"] == "stage_3"] - - # Pivot to compute density per pipeline - wide = df.pivot(index="pipeline", columns="metric", values="value") - - # Compute density = triple_count / entity_count (guard against zero/NaN) - denom = wide["entity_count"] - numer = wide["triple_count"] - density = np.where((denom > 0) & np.isfinite(denom), numer / denom, np.nan) - wide["density"] = density - - - # Return to long format: (pipeline, metric, value) - df = (wide.reset_index() - .melt(id_vars="pipeline", var_name="metric", value_name="value")) - - - def _normalize(group: pd.DataFrame): - vmax = group["value"].max() - vmin = group["value"].min() - - invert_normalization = False - if group.name == "density": - invert_normalization = True - - if invert_normalization: - group["normalized"] = norm_min(vmin, group["value"]) # largest→0, smallest→1 - else: - group["normalized"] = norm_max(vmax, group["value"]) #(group["value"] - vmin) / (vmax - vmin) # smallest→0, largest→1 - - return group - - df = df.groupby("metric", group_keys=False).apply(_normalize) - - return df - -def mean_scores(df, column_name): - df = df[["pipeline", "normalized"]] - # calculate the average of the metrics - df = df.groupby("pipeline").mean().reset_index() - # rename normalized to semantic - df = df.rename(columns={"normalized": column_name}) - return df - -def aggregate_ranking_df(): - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - - # # replace pipeline name with name_mapping - # metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name) - - # only pipelines where name contains 2 "_" chars - # metric_df = metric_df[metric_df["pipeline"].str.count("_") == 2] TODO - - norm_semantic_df = aggregate_semantic_metrics(metric_df) - norm_semantic_df = norm_semantic_df[["pipeline", "metric", "normalized"]] - agg_semantic_df = mean_scores(norm_semantic_df, "semantic") - - norm_reference_df = aggregate_reference_metrics(metric_df) - norm_reference_df = norm_reference_df[["pipeline", "metric", "normalized"]] - # print(norm_reference_df.to_string()) - agg_reference_df = mean_scores(norm_reference_df, "reference") - - norm_efficiency_df = aggregate_efficiency_metrics(metric_df) - # print(norm_efficiency_df) - norm_efficiency_df = norm_efficiency_df[["pipeline", "metric", "normalized"]] - agg_efficiency_df = mean_scores(norm_efficiency_df, "efficiency") - - norm_size_df = aggregate_size_metrics(metric_df) - norm_size_df = norm_size_df[["pipeline", "metric", "normalized"]] - agg_size_df = mean_scores(norm_size_df, "size") - - norm_df = pd.merge(norm_semantic_df, norm_reference_df, on=["pipeline", "metric", "normalized"], how="outer") - norm_df = pd.merge(norm_df, norm_efficiency_df, on=["pipeline", "metric", "normalized"], how="outer") - norm_df = pd.merge(norm_df, norm_size_df, on=["pipeline", "metric", "normalized"], how="outer") - - agg_df = pd.merge(agg_semantic_df, agg_reference_df, on=["pipeline"], how="left") - agg_df = pd.merge(agg_df, agg_efficiency_df, on=["pipeline"], how="left") - agg_df = pd.merge(agg_df, agg_size_df, on=["pipeline"], how="left") - - return norm_df, agg_df \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/paper/helpers/getter.py b/experiments/moviekg/src/moviekg/paper/helpers/getter.py deleted file mode 100644 index c5889a4..0000000 --- a/experiments/moviekg/src/moviekg/paper/helpers/getter.py +++ /dev/null @@ -1,557 +0,0 @@ - -import pandas as pd -from collections import defaultdict -import json -from typing import List, Callable - - -type pipeline_name = str -type stage_name = str -type metric_name = str -type metric_value = float -type pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]] -type pipeline_stage_metric_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]] - -""" -Helper file to map final metrics as kgpipe.evaluation... still in progress - -Each getter returns a nested dictionary of pipeline, stage, metric_name -{ - "pipeline": { - "stage": { - "metric_name": value - } - } -} -""" - -# Util - -def dict_for_metric_name(df: pd.DataFrame, metric_name: str, row_name: str = "value") -> pipeline_stage_dict: - df = df[df["metric"] == metric_name] - metric_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for index, row in df.iterrows(): - metric_dict[row["pipeline"]][row["stage"]] = row[row_name] - return metric_dict - -# Statistical metrics - -def sta_entity_count(df: pd.DataFrame): - # only pipeline, stage, value - return dict_for_metric_name(df, "entity_count") - -def sta_fact_count(df: pd.DataFrame): - return dict_for_metric_name(df, "triple_count") - -def sta_type_count(df: pd.DataFrame): - return dict_for_metric_name(df, "class_count") - -def sta_relation_count(df: pd.DataFrame): - return dict_for_metric_name(df, "relation_count") - -def sta_shallow_entity_count(df: pd.DataFrame): - return dict_for_metric_name(df, "shallow_entity_count") - -def sta_denisity(df: pd.DataFrame): - fact_count = sta_fact_count(df) - entity_count = sta_entity_count(df) - - metric_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for pipeline, stage_dict in fact_count.items(): - for stage, value in stage_dict.items(): - metric_dict[pipeline][stage] = value / entity_count[pipeline][stage] - - return metric_dict - -def sta_duration(df: pd.DataFrame): - return dict_for_metric_name(df, "duration") - -# def sta_memory_peak(df: pd.DataFrame): -# return dict_for_metric_name(df, "memory_peak") - -# Semantic metrics - -def sem_disjoint_domain(df: pd.DataFrame): - return dict_for_metric_name(df, "disjoint_domain", "normalized") - -def sem_incorrect_relation_direction(df: pd.DataFrame): - return dict_for_metric_name(df, "incorrect_relation_direction", "normalized") - -def sem_incorrect_relation_cardinality(df: pd.DataFrame): - return dict_for_metric_name(df, "incorrect_relation_cardinality", "normalized") - -def sem_incorrect_relation_range(df: pd.DataFrame): - return dict_for_metric_name(df, "incorrect_relation_range", "normalized") - -def sem_incorrect_relation_domain(df: pd.DataFrame): - return dict_for_metric_name(df, "incorrect_relation_domain", "normalized") - -def sem_incorrect_datatype(df: pd.DataFrame): - return dict_for_metric_name(df, "incorrect_datatype", "normalized") - -def sem_incorrect_datatype_format(df: pd.DataFrame): - return dict_for_metric_name(df, "incorrect_datatype_format", "normalized") - -# Reference metrics -def ref_kg_f1(df: pd.DataFrame): - df = df[df["metric"] == "ReferenceTripleAlignmentMetricSoftEV"] - - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - # print(details) - f1 = details.get("f1_score", -1) - res[row.pipeline][row.stage] = f1 - return res - -def ref_kg_p(df: pd.DataFrame): - df = df[df["metric"] == "ReferenceTripleAlignmentMetricSoftEV"] - - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - # print(details) - p = details["precision"] - res[row.pipeline][row.stage] = p - return res - -def ref_kg_r(df: pd.DataFrame): - df = df[df["metric"] == "ReferenceTripleAlignmentMetricSoftE"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - # print(details) - r = details["recall"] - res[row.pipeline][row.stage] = r - return res - -def ref_source_entity_f1(df: pd.DataFrame) -> pipeline_stage_dict: - df = df[df["metric"] == "SourceEntityPrecisionMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - expected_entities_count = details["expected_entities_count"] - found_entities_count = details["found_entities_count"] - overlapping_entities_count = details["overlapping_entities_count"] - possible_duplicates_count = details["possible_duplicates_count"] - overlapping_entities_strict_count = details["overlapping_entities_strict_count"] - - precision = overlapping_entities_strict_count / overlapping_entities_count - precision = precision if precision <= 1.0 else 1.0 - recall = overlapping_entities_count / expected_entities_count - recall = recall if recall <= 1.0 else 1.0 - f1 = 2 * (precision * recall) / (precision + recall) - df.loc[row.Index, "normalized"] = f1 - res[row.pipeline][row.stage] = f1 - return res - -def ref_source_entity_p(df: pd.DataFrame): - df = df[df["metric"] == "SourceEntityPrecisionMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - precision = details["overlapping_entities_strict_count"] / details["overlapping_entities_count"] - res[row.pipeline][row.stage] = precision - return res - -def ref_source_entity_r(df: pd.DataFrame): - df = df[df["metric"] == "SourceEntityPrecisionMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - recall = details["overlapping_entities_count"] / details["expected_entities_count"] - res[row.pipeline][row.stage] = recall - return res - -def ref_source_typed_entity_fn(df: pd.DataFrame): - df = df[df["metric"] == "SourceTypedEntityCoverageMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - # print(details) - fn = details.get("fn", -1) - res[row.pipeline][row.stage] = fn - return res - -def ref_source_typed_entity_p(df: pd.DataFrame): - df = df[df["metric"] == "SourceTypedEntityCoverageMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - # print(details) - precision = details.get("precision", -1) - res[row.pipeline][row.stage] = precision - return res - -def ref_source_typed_entity_r(df: pd.DataFrame): - df = df[df["metric"] == "SourceTypedEntityCoverageMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - recall = details.get("recall", -1) - res[row.pipeline][row.stage] = recall - return res - -def ref_entity_matching_f1(df: pd.DataFrame): - df = df[df["metric"] == "ER_EntityMatchMetric"] - - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - # print(details) - tp = details["true_seed_match_cnt"] - fp = details["false_seed_match_cnt"] - fn = details["false_missing_seed_match_cnt"] - f1 = 2 * tp / (2 * tp + fp + fn) - res[row.pipeline][row.stage] = f1 - return res - -def ref_entity_matching_p(df: pd.DataFrame): - df = df[df["metric"] == "ER_EntityMatchMetric"] - - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - # print(details) - tp = details["true_seed_match_cnt"] - fp = details["false_seed_match_cnt"] - fn = details["false_missing_seed_match_cnt"] - precision = tp / (tp + fp) - res[row.pipeline][row.stage] = precision - return res - -def ref_entity_matching_r(df: pd.DataFrame): - df = df[df["metric"] == "ER_EntityMatchMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - # print(details) - tp = details["true_seed_match_cnt"] - fp = details["false_seed_match_cnt"] - fn = details["false_missing_seed_match_cnt"] - recall = tp / (tp + fn) - res[row.pipeline][row.stage] = recall - return res - -RM_DEFAULT_FN=24 # 23 + label - -def ref_relation_matching_f1(df: pd.DataFrame): - df = df[df["metric"] == "ER_RelationMatchMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - # print(details) - tp = details["true_relation_match_cnt"] - fp = details["false_relation_match_cnt"] - fn = RM_DEFAULT_FN - (tp+fp) # details.get("false_missing_relation_match_cnt", 0) - f1 = 2 * tp / (2 * tp + fp + fn) - res[row.pipeline][row.stage] = f1 - return res - - -def ref_relation_matching_p(df: pd.DataFrame): - df = df[df["metric"] == "ER_RelationMatchMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - # print(details) - tp = details["true_relation_match_cnt"] - fp = details["false_relation_match_cnt"] - fn = RM_DEFAULT_FN - (tp+fp) # details.get("false_missing_relation_match_cnt", 0) - print(f"tp, fp, fn for {row.pipeline} {row.stage}: {tp}, {fp}, {fn}") - precision = tp / (tp + fp) if (tp + fp) > 0 else 0 - res[row.pipeline][row.stage] = precision - return res - - -def ref_relation_matching_r(df: pd.DataFrame): - df = df[df["metric"] == "ER_RelationMatchMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - # print(details) - tp = details["true_relation_match_cnt"] - fp = details["false_relation_match_cnt"] - fn = RM_DEFAULT_FN - (tp+fp) # details.get("false_missing_relation_match_cnt", 0) - recall = tp / (tp + fn) if (tp + fn) > 0 else 0 - res[row.pipeline][row.stage] = recall - return res - -def ref_entity_linking_r(df: pd.DataFrame): - df = df[df["metric"] == "TE_ExpectedEntityLinkMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - # print(details) - tp = details["true_link_cnt"] - fp = details["false_link_cnt"] - fn = details["false_missing_link_cnt"] - r = tp / (tp + fn) if (tp + fn) > 0 else 0 - res[row.pipeline][row.stage] = r - return res - -def ref_json_entity_matching_f1(df: pd.DataFrame): - df = df[df["metric"] == "JsonEntityMatchingMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - res[row.pipeline][row.stage] = details["f1_score"] - return res - -def ref_json_entity_matching_p(df: pd.DataFrame): - df = df[df["metric"] == "JsonEntityMatchingMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - res[row.pipeline][row.stage] = details["precision"] - return res - -def ref_json_entity_matching_r(df: pd.DataFrame): - df = df[df["metric"] == "JsonEntityMatchingMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - res[row.pipeline][row.stage] = details["recall"] - return res - -def ref_json_entity_linking_r(df: pd.DataFrame): - df = df[df["metric"] == "JsonEntityLinkingMetric"] - res: pipeline_stage_dict = defaultdict[pipeline_name, defaultdict[stage_name, metric_value]](lambda: defaultdict[stage_name, metric_value](lambda: None)) - for row in df.itertuples(): - details = json.loads(row.details) - if "error" in details: - res[row.pipeline][row.stage] = -1 - continue - res[row.pipeline][row.stage] = details["recall"] - return res - -TABLE_DISPLAY_NAMES = { - # Statistical metrics - sta_entity_count.__name__ : "EC", - sta_fact_count.__name__: "FC", - sta_type_count.__name__: "TC", - sta_relation_count.__name__: "RC", - sta_shallow_entity_count.__name__: "SEC", - sta_denisity.__name__: "D", - sta_duration.__name__: "T", - # sta_memory_peak.__name__: "M", - # Semantic metrics - sem_disjoint_domain.__name__: "ODT", - sem_incorrect_relation_direction.__name__: "ORD", - sem_incorrect_relation_cardinality.__name__: "OCA", - sem_incorrect_relation_range.__name__: "OR", - sem_incorrect_relation_domain.__name__: "OD", - sem_incorrect_datatype.__name__: "OLT", - sem_incorrect_datatype_format.__name__: "OLF", - # Reference metrics - ref_kg_f1.__name__: "RTC", - ref_kg_p.__name__: "RTC-SoftE", - ref_kg_r.__name__: "RTC-SoftE-R", - ref_source_entity_f1.__name__: "VSEC", - ref_source_entity_p.__name__: "VSEC-P", - ref_source_entity_r.__name__: "VSEC-R", - ref_source_typed_entity_p.__name__: "VSEC-P-TE", - ref_source_typed_entity_r.__name__: "VSEC-R-TE", - ref_source_typed_entity_fn.__name__: "VSEC-FN-TE", - ref_entity_matching_f1.__name__: "ER-EM", - ref_entity_matching_p.__name__: "ER-EM-P", - ref_entity_matching_r.__name__: "ER-EM-R", - ref_relation_matching_f1.__name__: "ER-RM", - ref_relation_matching_p.__name__: "ER-RM-P", - ref_relation_matching_r.__name__: "ER-RM-R", - ref_entity_linking_r.__name__: "TE-EEL", - ref_json_entity_matching_f1.__name__: "JSON-EM", - ref_json_entity_matching_p.__name__: "JSON-EM-P", - ref_json_entity_matching_r.__name__: "JSON-EM-R", - ref_json_entity_linking_r.__name__: "JSON-EL", -} - -def dict_of_metrics(df: pd.DataFrame, metric_getters: List[Callable[[pd.DataFrame], dict]]) -> pipeline_stage_metric_dict: - """ - # call the getter functions for each metric name not the dict_for_metric_name - """ - - # Create a 3-level nested defaultdict: pipeline -> stage -> metric_name -> value - metric_dict = defaultdict(lambda: defaultdict(dict)) - - for metric_getter in metric_getters: - metric_name = metric_getter.__name__ # the metric name (e.g., "sta_entity_count") - metric_data = metric_getter(df) # returns pipeline->stage->value - - if metric_data is None: - continue - - for pipeline, stage_dict in metric_data.items(): - for stage, value in stage_dict.items(): - metric_dict[pipeline][stage][metric_name] = value - - return metric_dict - -def get_pipeline_stage_metric_dict(df: pd.DataFrame, metric_names: List[str]) -> pipeline_stage_metric_dict: - """ - # call the getter functions for each metric name not the dict_for_metric_name - """ - return dict_of_metrics(df, [globals()[f"{metric_name.lower()}"] for metric_name in TABLE_DISPLAY_NAMES.keys()]) - - -def normalize_min_best(values: List[float], value: float) -> float: - def norm_min(min, value): - return (min/value) - return norm_min(min(values), value) - - -def normalize_max_best(values: List[float], value: float) -> float: - # print(f"values: {values}, value: {value}") - def norm_max(max, value): - return 1 / (max/value) - return norm_max(max(values), value) - - -def normalize_metric(psmd: pipeline_stage_metric_dict, metric_name: str, stages: List[str], func: Callable[[list[float], float], float]) -> pipeline_stage_metric_dict: - values_for_metric = [] - - pipelines_to_normalize = [] - stages_to_normalize = [] - - - for pipeline, stage_dict in psmd.items(): - pipelines_to_normalize.append(pipeline) - for stage, metric_dict in stage_dict.items(): - if stage not in stages or metric_name not in metric_dict: - continue - stages_to_normalize.append(stage) - values_for_metric.append(metric_dict[metric_name]) - - for pipeline in pipelines_to_normalize: - for stage in stages_to_normalize: - if metric_name not in psmd[pipeline][stage]: - continue - value = psmd[pipeline][stage][metric_name] - if metric_name == sta_fact_count.__name__: - if pipeline in ["json_llm_mapping_v1", "text_llm_triple_extract_v1"]: - values_for_metric=[65000] - else: - values_for_metric=[340000] - print(f"setting max ec for norm {pipeline} {values_for_metric}") - psmd[pipeline][stage][metric_name+"_norm"] = func(values_for_metric, value) - - return psmd - -def update_task_selected_task_metric(psmd: pipeline_stage_metric_dict, metric_name: str) -> pipeline_stage_metric_dict: - - for pipleine, stage_dict in psmd.items(): - if pipleine in ["reference", "seed"]: - continue - for stage, metric_dict in stage_dict.items(): - entity_matching_f1 = metric_dict.get(ref_entity_matching_f1.__name__, -1) - relation_matching_f1 = metric_dict.get(ref_relation_matching_f1.__name__, -1) - entity_linking_r = metric_dict.get(ref_entity_linking_r.__name__, -1) - json_entity_matching_f1 = metric_dict.get(ref_json_entity_matching_f1.__name__, -1) - json_entity_linking_r = metric_dict.get(ref_json_entity_linking_r.__name__, -1) - - if json_entity_matching_f1 != -1: - metric_dict[metric_name] = json_entity_matching_f1 - metric_dict[metric_name+"_spec"] = "JSON ER" - elif entity_matching_f1 != -1: - metric_dict[metric_name] = (entity_matching_f1 + relation_matching_f1) / 2 - metric_dict[metric_name+"_spec"] = "RDF ER" - elif json_entity_linking_r != -1: - metric_dict[metric_name] = json_entity_linking_r - metric_dict[metric_name+"_spec"] = "JSON EL" - else: - metric_dict[metric_name] = entity_linking_r - metric_dict[metric_name+"_spec"] = "TE" - - return psmd - -def agg_avg(values: list[float]) -> float: - return sum(values) / len(values) - -def agg_sum(values: list[float]) -> float: - return sum(values) - -def agg_metric_over_stages(psmd: pipeline_stage_metric_dict, metric_name: str, suffix: str, agg_func: Callable[[list[float]], float]) -> pipeline_stage_metric_dict: - - values_for_metric_by_pipeline = defaultdict[pipeline_name, list[float]](lambda: []) - pipelines_to_agg = [] - stages_to_agg = [] - - for pipeline, stage_dict in psmd.items(): - if pipeline in ["reference", "seed"]: - continue - pipelines_to_agg.append(pipeline) - for stage, metric_dict in stage_dict.items(): - if metric_name not in metric_dict: - continue - stages_to_agg.append(stage) - values_for_metric_by_pipeline[pipeline].append(metric_dict[metric_name]) - - for pipeline in pipelines_to_agg: - try: - psmd[pipeline]["stage_3"][metric_name+suffix] = agg_func(values_for_metric_by_pipeline[pipeline]) - except Exception as e: - print(f"Error aggregating metric {metric_name} for pipeline {pipeline}: {e}") - print(values_for_metric_by_pipeline[pipeline]) - psmd[pipeline]["stage_3"][metric_name+suffix] = 0 - return psmd - -def apply_selected_updates(psmd: pipeline_stage_metric_dict) -> pipeline_stage_metric_dict: - normalize_metric(psmd, "sta_entity_count", ["stage_3"], normalize_max_best) - update_task_selected_task_metric(psmd, "ref_selected_task_metric") - agg_metric_over_stages(psmd, "ref_selected_task_metric", "_avg", agg_avg) - agg_metric_over_stages(psmd, "sta_duration", "_sum", agg_sum) - agg_metric_over_stages(psmd, "ref_source_entity_f1", "_avg", agg_avg) - agg_metric_over_stages(psmd, "ref_kg_f1", "_avg", agg_avg) - return psmd - -def test_getter(): - from pathlib import Path - from moviekg.paper.helpers.helpers import load_metrics_from_file - print(TABLE_DISPLAY_NAMES.keys()) - df = load_metrics_from_file(Path("/home/marvin/project/data/out/large") / "all_metrics.csv") - psmd = dict_of_metrics(df, [globals()[f"{metric_name.lower()}"] for metric_name in TABLE_DISPLAY_NAMES.keys()]) - - - apply_selected_updates(psmd) - - for pipeline, stage_dict in psmd.items(): - for stage, metric_dict in stage_dict.items(): - if pipeline in ["reference", "seed"]: - continue - # print(f"{pipeline} {stage} {metric_dict['ref_selected_task_metric']} {metric_dict['ref_selected_task_metric_spec']}") - if "stage_3" == stage: - # print(f"{pipeline} {stage} {metric_dict['ref_selected_task_metric_agg']}") - print(pipeline) - print(stage) - print(json.dumps(metric_dict, indent=4)) - print("--------------------------------") \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/paper/helpers/helpers.py b/experiments/moviekg/src/moviekg/paper/helpers/helpers.py deleted file mode 100644 index 2dd3200..0000000 --- a/experiments/moviekg/src/moviekg/paper/helpers/helpers.py +++ /dev/null @@ -1,777 +0,0 @@ -from matplotlib.font_manager import font_scalings -import pandas as pd -import numpy as np -import matplotlib.pyplot as plt -import seaborn as sns -import json -from matplotlib.patches import Patch -from matplotlib.ticker import ScalarFormatter -from typing import Dict -import re -import pandas as pd - -import pandas as pd -from typing import List, Optional - -from moviekg.paper.config import HEADERS, main_classes -from moviekg.pipelines.test_inc_ssp import pipeline_types, llm_pipeline_types - - -def load_metrics_from_file(file_path): - # print("Loading metrics from file: ", file_path) - df = pd.read_csv(file_path, names=HEADERS, skiprows=1) - return df - -def plot_growth_v1(df, metrics): - """ - df: pandas DataFrame with columns: - pipeline, stage, aspect, metric, value, normalized, details - metrics: list[str] of metric names to plot - - Generates a subplot for each metric. - Each subplot has x-axis: stage, y-axis: value. - Each pipeline's value is a grouped bar at each stage. - Returns (fig, axes). - """ - required_cols = {"pipeline", "stage", "aspect", "metric", "value", "normalized", "details"} - missing = required_cols - set(df.columns) - if missing: - raise ValueError(f"DataFrame is missing required columns: {sorted(missing)}") - - if not isinstance(metrics, (list, tuple)) or len(metrics) == 0: - raise ValueError("`metrics` must be a non-empty list of metric names.") - - # Only keep rows for requested metrics - plot_df = df[df["metric"].isin(metrics)].copy() - if plot_df.empty: - raise ValueError("No rows found for the requested metrics.") - - # Create subplots - n_metrics = len(metrics) - fig, axes = plt.subplots(n_metrics, 1, figsize=(10, max(3.5, 2.8 * n_metrics)), squeeze=False) - axes = axes.ravel() - - # Overall (stable) pipeline order: alphabetical for consistency - all_pipelines = sorted(plot_df["pipeline"].dropna().unique().tolist()) - - for ax, metric in zip(axes, metrics): - mdf = plot_df[plot_df["metric"] == metric].copy() - if mdf.empty: - ax.set_visible(False) - continue - - # Preserve stage order as first-appearance order for this metric - stage_order = pd.Index(mdf["stage"].dropna().astype(str)).drop_duplicates().tolist() - if not stage_order: - ax.set_visible(False) - continue - - # Pivot to stage x pipeline = values - pivot = ( - mdf.assign(stage=pd.Categorical(mdf["stage"].astype(str), categories=stage_order, ordered=True)) - .pivot_table( - index="stage", - columns="pipeline", - values="value", - aggfunc="sum", - ) - .reindex(columns=all_pipelines) # ensure consistent pipeline order - .sort_index() - ) - - # If some pipelines/stages don't exist, fill with 0 (or use NaN if you prefer gaps) - vals = pivot.fillna(0.0).values - stages = pivot.index.astype(str).tolist() - pipelines = pivot.columns.astype(str).tolist() - - n_stages = len(stages) - n_pipes = max(1, len(pipelines)) - - x = np.arange(n_stages, dtype=float) - total_width = 0.8 - bar_w = total_width / n_pipes - - # Center the grouped bars around each stage tick - start = x - (total_width / 2) + (bar_w / 2) - - for i, pipe in enumerate(pipelines): - y = pivot[pipe].fillna(0.0).to_numpy() - ax.bar(start + i * bar_w, y, width=bar_w, label=pipe) - - ax.set_title(str(metric)) - ax.set_xlabel("stage") - ax.set_ylabel("value") - ax.set_xticks(x) - ax.set_xticklabels(stages, rotation=0, ha="center") - - # Only show legend if multiple pipelines - if n_pipes > 1: - ax.legend(title="pipeline", frameon=False, ncols=min(3, n_pipes)) - ax.grid(axis="y", linestyle=":", linewidth=0.7, alpha=0.6) - - fig.tight_layout() - return fig, axes - -# --- Hardcoded pipeline colors (light/dark for solos; mid-tone for combined) -PALETTE = { - # JSON solo - "json_a": "#9ecae1", "json_b": "#1f77b4", "json_c": "21f77b4", - # "json_baseA": "#9ecae1", - # RDF solo - "rdf_a": "#a1d99b", "rdf_b": "#2ca02c", "rdf_c": "#3ca02c", - # TEXT solo - "text_a": "#fdd0a2", "text_b": "#ff7f0e", "text_c": "#ff7f0e", - - # JSON mixed → violet - "json_rdf_text": "#756bb1", "json_text_rdf": "#756bb1", - # RDF mixed → teal - "rdf_json_text": "#1c9099", "rdf_text_json": "#1c9099", - # TEXT mixed → red-brown - "text_json_rdf": "#d95f0e", "text_rdf_json": "#d95f0e", -} - -PALETTE_2 = { - # JSON solo - "JSON_base": "#9ecae1", - # "json_baseA": "#9ecae1", - # RDF solo - "RDF_base": "#a1d99b", "RDF_llm": "#2ca02c", - # TEXT solo - "TEXT_base": "#fdd0a2", - - # JSON mixed → violet - "json_rdf_text": "#756bb1", "json_text_rdf": "#756bb1", - # RDF mixed → teal - "rdf_json_text": "#1c9099", "rdf_text_json": "#1c9099", - # TEXT mixed → red-brown - "text_json_rdf": "#d95f0e", "text_rdf_json": "#d95f0e", -} - - -HUE_ORDER = [ - "json_a","json_b","json_rdf_text","json_text_rdf", - "rdf_a","rdf_b","rdf_json_text","rdf_text_json", - "text_a","text_b","text_json_rdf","text_rdf_json" -] -HUE_ORDER_2 = [ - "RDF_base","RDF_llm","rdf_json_text","rdf_text_json", - "JSON_base","json_rdf_text","json_text_rdf", - "TEXT_base","text_json_rdf","text_rdf_json" -] - -def plot_growth(df, metrics, kind="bar", references={}): - """ - df: pandas DataFrame with columns: - pipeline, stage, aspect, metric, value, normalized, details - metrics: list[str] of metric names to plot - kind: "bar" or "line" - - Generates a facet plot (subplot per metric). - Each subplot has x-axis: stage, y-axis: value, - with different pipelines distinguished by color. - """ - required_cols = {"pipeline", "stage", "aspect", "metric", "value", "normalized", "details"} - missing = required_cols - set(df.columns) - if missing: - raise ValueError(f"DataFrame is missing required columns: {sorted(missing)}") - - if not metrics: - raise ValueError("`metrics` must be a non-empty list of metric names.") - - # Filter to requested metrics - plot_df = df[df["metric"].isin(metrics)].copy() - if plot_df.empty: - raise ValueError("No rows found for the requested metrics.") - - # Consistent style - sns.set(style="whitegrid") - - stage_order = list(dict.fromkeys(plot_df["stage"])) - - # sns.set_context("notebook", font_scale=1.2) - - # Facet grid WITHOUT hue to avoid legend kwarg collisions - g = sns.FacetGrid( - plot_df, - col="metric", - col_wrap=len(metrics), - height=len(metrics)*1.6, - aspect=1.5, - sharey=False, - col_order=metrics, - legend_out=False, - ) - - if kind != "bar": - raise ValueError("`kind` must be 'bar' for per-bar labels.") - - # Draw grouped bars with hue specified inside map_dataframe - g.map_dataframe( - sns.barplot, - x="stage", - y="value", - hue="pipeline", - hue_order=HUE_ORDER, - palette=PALETTE, - order=stage_order, - dodge=True, - errorbar=None - ) - - - try: - g._legend.remove() - except Exception: - pass - - # build a single combined legend below everything - handles, labels = g.axes[0].get_legend_handles_labels() - g.fig.legend( - handles, labels, - loc="lower center", - ncol=min(6, len(labels)), # 6 items per row (→ 2 rows for 12 pipelines) - bbox_to_anchor=(0.5, -0.1), # adjust vertical offset - frameon=False - ) - - for ax_idx, ax in enumerate(g.axes.flat): - - # remove x axis label - ax.set_xlabel("") - - # numbers 1 to 3 - for stage_idx in range(1, 4): - value, nvalue, details = get_reference_value(df, metrics[ax_idx], "stage_"+str(stage_idx)) - # print(metrics[ax_idx], value) - xpos = stage_idx - if stage_idx == 0: - ax.axhline(value, ls="--", color="red") - else: - ax.axhline(value, ls="--", color="black") - - for ax in g.axes.flat: - ax.set_xlabel("") - # tidy up axes - ax.set_xticks(range(len(stage_order))) - ax.set_xticklabels(stage_order) - ax.yaxis.set_major_formatter(ScalarFormatter(useMathText=True)) - ax.ticklabel_format(style='sci', axis='y', scilimits=(0,0)) - ax.grid(True, axis="y", linestyle="--", alpha=0.3) - ax.margins(x=0.02) - - return g - -def _stage_sort_key(s): - """ - Convert 'stage_3' -> 3 for natural sorting; unknown formats go to +inf. - """ - m = re.search(r"(\d+)$", str(s)) - return int(m.group(1)) if m else float("inf") - -def _shorten_iri(iri): - """ - Turn 'http://kg.org/ontology/Person' -> 'Person' for cleaner legends. - """ - return str(iri).rstrip("/").split("/")[-1] - -def _flatten_to_df(nested): - """ - nested: dict like { - 'rdf_a': {'stage_1': {'iri': count, ...}, ...}, - 'reference': {...}, - ... - } - Returns a tidy DataFrame with columns: - Pipeline, Stage, Class, Actual, Expected - """ - - # Split out reference (Expected) from others (Actual) - if "reference" not in nested: - raise ValueError("Input must contain a 'reference' key with expected counts.") - ref = nested["reference"] - pipelines = {k: v for k, v in nested.items() if k != "reference"} - - # Collect all stages/classes across data to ensure aligned zeros - all_stages = sorted( - {s for d in nested.values() for s in d.keys()}, - key=_stage_sort_key - ) - - - - all_classes = sorted( - {c for d in nested.values() for s in d.values() for c in s.keys()} - ) - - - - # Build rows - rows = [] - for pipe, pdata in pipelines.items(): - for stage in all_stages: - for cls in all_classes: - actual = pdata.get(stage, {}).get(cls, 0) - expected = ref.get(stage, {}).get(cls, 0) - if cls not in main_classes: - cls = "Other" - rows.append({ - "Pipeline": pipe, - "Stage": stage, - "Class": cls, - "Actual": actual, - "Expected": expected, - "Class Short": _shorten_iri(cls), - }) - return pd.DataFrame(rows), [ _shorten_iri(c) for c in all_classes ], all_stages, list(pipelines.keys()) - -import pandas as pd -import matplotlib.pyplot as plt -from matplotlib.patches import Patch -import seaborn as sns - -def plot_actual_expected_stacked(df, - pipeline_order=None, - stage_order=None, - class_order=None, - col_wrap=3, - height=4, - suptitle="Actual vs Expected (stacked by Class) per Pipeline & Stage"): - # --- prep --- - df = df.copy() - # ensure numeric & fill NAs - for col in ["Actual", "Expected"]: - df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0) - - # Use Class Short as plotting label (cleaner legend) - if "Class Short" not in df.columns: - df["Class Short"] = df["Class"] - - # Default orders (preserve first-seen order) - if pipeline_order is None: - pipeline_order = list(pd.unique(df["Pipeline"])) - if stage_order is None: - stage_order = list(pd.unique(df["Stage"])) - if class_order is None: - class_order = list(pd.unique(df["Class Short"])) - - # aggregate once - gdf = ( - df.groupby(["Pipeline", "Stage", "Class Short"], as_index=False) - .agg(Actual=("Actual","sum"), Expected=("Expected","sum")) - ) - - # full grid to align missing combos to 0 - full_index = pd.MultiIndex.from_product( - [pipeline_order, stage_order], names=["Pipeline","Stage"] - ) - - # pivots: (Pipeline, Stage) × Class - actual = (gdf.pivot_table(index=["Pipeline","Stage"], columns="Class Short", - values="Actual", aggfunc="sum") - .reindex(full_index) - .reindex(columns=class_order) - .fillna(0)) - expected = (gdf.pivot_table(index=["Pipeline","Stage"], columns="Class Short", - values="Expected", aggfunc="sum") - .reindex(full_index) - .reindex(columns=class_order) - .fillna(0)) - - # --- plot --- - sns.set(style="whitegrid") - n_pipes = len(pipeline_order) - ncols = min(col_wrap, n_pipes) - nrows = (n_pipes + ncols - 1) // ncols - fig, axes = plt.subplots(nrows, ncols, figsize=(ncols*height*1.6, nrows*height), squeeze=False, constrained_layout=True) - axes = axes.flatten() - - # palettes - blues = sns.color_palette("Blues", n_colors=max(3, len(class_order))) - oranges = sns.color_palette("Oranges", n_colors=max(3, len(class_order))) - color_map_actual = {cls: blues[i % len(blues)] for i, cls in enumerate(class_order)} - color_map_expected = {cls: oranges[i % len(oranges)] for i, cls in enumerate(class_order)} - - width = 0.4 - for ax, pipeline in zip(axes, pipeline_order): - act = actual.loc[pipeline] # index=Stage, cols=Class Short - exp = expected.loc[pipeline] # index=Stage, cols=Class Short - - x = range(len(stage_order)) - - # stacked bars - bottom_a = [0.0]*len(stage_order) - bottom_e = [0.0]*len(stage_order) - - for cls in class_order: - a_vals = act[cls].to_numpy() - e_vals = exp[cls].to_numpy() - - ax.bar([xi - 0.2 for xi in x], a_vals, width=width, bottom=bottom_a, color=color_map_actual[cls], edgecolor="none", label="Actual") - ax.bar([xi + 0.2 for xi in x], e_vals, width=width, bottom=bottom_e, color=color_map_expected[cls], edgecolor="none", label="Expected") - - # update bottoms - bottom_a = [b + v for b, v in zip(bottom_a, a_vals)] - bottom_e = [b + v for b, v in zip(bottom_e, e_vals)] - - # cosmetics - ax.set_title(pipeline) - ax.set_xticks(list(x)) - ax.set_xticklabels(stage_order) - ax.set_xlabel("Stage") - ax.set_ylabel("Count") - ax.grid(axis="y", linestyle=":", linewidth=0.7, alpha=0.6) - - # hide any unused axes - for j in range(len(pipeline_order), len(axes)): - fig.delaxes(axes[j]) - - # legend - handles = ( - [Patch(facecolor=color_map_actual[c], label=f"{c} • Actual") for c in class_order] + - [Patch(facecolor=color_map_expected[c], label=f"{c} • Expected") for c in class_order] - ) - - # legend (robust placement) - ncol_leg = min(4, len(handles)) - nrows_leg = int(np.ceil(len(handles) / ncol_leg)) - - leg = fig.legend( - handles=handles, - loc="lower center", - ncol=ncol_leg, - bbox_to_anchor=(0.5, 0.02), # inside the figure, just above bottom - frameon=False - ) - - # Title inside the top of the figure - fig.suptitle(suptitle, y=0.99, fontsize=14) - - # Give the legend guaranteed space at the bottom, proportional to its rows - # (works alongside constrained_layout) - plt.subplots_adjust(bottom=0.08 + 0.05 * max(0, nrows_leg - 1)) - - return fig - - -def plot_expected_actual_from_nested( - nested, - col_wrap=3, - height=4, - suptitle="Actual vs Expected (stacked by Class) per Pipeline & Stage" -): - """ - nested: dict structured like the user's example. - Creates one subplot per pipeline. For each Stage on that subplot, - draws two stacked bars (Actual & Expected), each stacked by Class. - """ - - df, class_labels, stage_order, pipeline_order = _flatten_to_df(nested) - - # We’ll use the *short* class labels for stacking order & legend - classes = class_labels - - # Prepare nice style - sns.set(style="whitegrid") - g = sns.FacetGrid( - df, - col="Pipeline", - col_wrap=col_wrap, - height=height, - sharey=True, - col_order=pipeline_order - ) - - df[['Actual','Expected']] = df[['Actual','Expected']].fillna(0) - - # Aggregate by Pipeline, Stage, Class, and Class Short - df = ( - df.groupby(['Pipeline', 'Stage', 'Class', 'Class Short'], as_index=False) - .agg({'Actual': 'sum', 'Expected': 'sum'}) - ) - - return plot_actual_expected_stacked(df, pipeline_order, stage_order, ["Other", "Person", "Company", "Film"], col_wrap, height, suptitle) - - -def plot_class_occurence(df): - """ - df: pandas dataframe with columns: pipeline, stage, aspect, metric, value, normalized, details - """ - - # filter df for metrics - df = df[df["metric"].isin(["class_occurrence"])] - # filter details contains unique_classes - df = df[df["details"].str.contains("unique_classes")] - # remove duration column - df = df.drop(columns=["duration"]) - # filter not seed pipeline - df = df[df["pipeline"] != "seed"] - - - class_counts_by_stage_by_pipeline = {} - # for each row - for index, row in df.iterrows(): - details = json.loads(row["details"]) - classes = details["classes"] - if row["pipeline"] not in class_counts_by_stage_by_pipeline: - class_counts_by_stage_by_pipeline[row["pipeline"]] = {} - # skip stage 0 - if row["stage"] == "stage_0": - continue - if row["stage"] not in class_counts_by_stage_by_pipeline[row["pipeline"]]: - class_counts_by_stage_by_pipeline[row["pipeline"]][row["stage"]] = {} - for class_name, count in classes.items(): - if class_name not in class_counts_by_stage_by_pipeline[row["pipeline"]][row["stage"]]: - class_counts_by_stage_by_pipeline[row["pipeline"]][row["stage"]][class_name] = 0 - class_counts_by_stage_by_pipeline[row["pipeline"]][row["stage"]][class_name] += count - - # remove stage_0 - class_counts_by_stage_by_pipeline = {k: v for k, v in class_counts_by_stage_by_pipeline.items() if k != "stage_0"} - - return plot_expected_actual_from_nested(class_counts_by_stage_by_pipeline, col_wrap=2, height=4, suptitle="Actual vs Reference by Stage • Stacked by Class") - - -def rank_pipeline_stage(group_df, metric_names, metric_weights): - weights = pd.Series(metric_weights, index=metric_names) - vals = ( - group_df.set_index("metric")["normalized"] - .reindex(metric_names) # align order - .astype(float) - ) - return float((vals * weights).sum()/len(vals)) - -def rank_metrics_apply(df, metric_names, metric_weights): - dff = df[df["metric"].isin(metric_names)] - return ( - dff.groupby(["pipeline", "stage"]) - .apply(lambda g: rank_pipeline_stage(g, metric_names, metric_weights)) - .rename("score") - .reset_index() - ) - - -def rank_metrics( - df: pd.DataFrame, - metric_names: List[str], - metric_weights: List[float], - *, - agg: str = "mean", - fill_missing: Optional[float] = 0.0, - score_col: str = "score", -) -> pd.DataFrame: - """ - Compute a weighted score per (pipeline, stage) using normalized metric values. - - Parameters - ---------- - df : DataFrame - Must include columns: pipeline, stage, metric, normalized - (other columns are ignored). - metric_names : list of str - Names of metrics to include, in the same order as their weights. - metric_weights : list of float - Weights aligned to metric_names. - agg : {"mean","sum","max","min"}, default "mean" - If there are duplicate rows per (pipeline, stage, metric), how to aggregate. - fill_missing : float or None, default 0.0 - Value to fill when a metric is missing for a (pipeline, stage). - Use None to leave as NaN (then the final score may be NaN). - score_col : str, default "score" - Name of the output score column. - - Returns - ------- - DataFrame with columns: pipeline, stage, - """ - if len(metric_names) != len(metric_weights): - raise ValueError("metric_names and metric_weights must have the same length") - - # Keep only what we need - dff = df.loc[df["metric"].isin(metric_names), ["pipeline", "stage", "metric", "normalized"]] - - # Aggregate duplicates per (pipeline, stage, metric) - agg_map = {"mean": "mean", "sum": "sum", "max": "max", "min": "min"} - if agg not in agg_map: - raise ValueError(f'agg must be one of {list(agg_map)}') - pivot = dff.pivot_table( - index=["pipeline", "stage"], - columns="metric", - values="normalized", - aggfunc=agg_map[agg], - ) - - # Enforce column order and align with weights - pivot = pivot.reindex(columns=metric_names) - if fill_missing is not None: - pivot = pivot.fillna(fill_missing) - - weights = pd.Series(metric_weights, index=metric_names) - scores = pivot.dot(weights).rename(score_col) - - return scores.reset_index() - -def get_reference_value(df, metric_name, stage): - df = df[df["metric"] == metric_name] - df = df[df["stage"] == stage] - df = df[df["pipeline"] == "reference"] - # print(df.to_string()) - value = df["value"].values[0] - nvalue = df["normalized"].values[0] - details = json.loads(df["details"].values[0]) - return value, nvalue, details - - -def get_reference_class_counts(df) -> Dict[str, Dict[str, int]]: - df = df[df["pipeline"] == "reference"] - reference_stage_class_count: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int)) - df = df[df["metric"] == "class_occurrence"] - for stage in df["stage"].unique(): - df_stage = df[df["stage"] == stage] - details = json.loads(df_stage["details"].values[0]) - class_counts = details["classes"] - for class_name, count in class_counts.items(): - reference_stage_class_count[stage][class_name.split("/")[-1]] += count - - return reference_stage_class_count - -# def subplot_source_entity_integration(df): -# pass - -from collections import defaultdict - -def plot_class_occurence_new(df, reference_stage_class_count, classes): - - df = df[df["metric"] == "class_occurrence"] - - - pipeline_stage_class_count = defaultdict(lambda: defaultdict(lambda: defaultdict(int))) - - rows = [] - - # for each pipeline and stage - for pipeline in df["pipeline"].unique(): - for stage in df["stage"].unique(): - df_pipeline_stage = df[df["pipeline"] == pipeline] - df_pipeline_stage = df_pipeline_stage[df_pipeline_stage["stage"] == stage] - details = json.loads(df_pipeline_stage["details"].values[0]) - class_counts = details["classes"] - for class_name, count in class_counts.items(): - if class_name not in classes: - class_name = "Other" - pipeline_stage_class_count[pipeline][stage][class_name] += count - - pretty_pipeline_names = { - "json_a": "JSON_base", - "json_c": "JSON_llm", - "rdf_a": "RDF_base", - "rdf_c": "RDF_llm", - "text_a": "TEXT_base", - "text_c": "TEXT_llm", - "json_llm_mapping_v1": "JSON_llm", - "rdf_llm_schema_align_v1": "RDF_llm", - "text_llm_triple_extract_v1": "TEXT_llm", - } - - # convert dict of dict to rows - for pipeline, stage_class_count in pipeline_stage_class_count.items(): - for stage, class_count in stage_class_count.items(): - for class_name, count in class_count.items(): - rows.append({"pipeline": pretty_pipeline_names.get(pipeline, pipeline), "stage": stage, "class": class_name.split("/")[-1], "count": count}) - - # df: pipeline, stage, class, count - df = pd.DataFrame(rows) - df = df[df["class"] != "Other"] - - classes_short = [class_name.split("/")[-1] for class_name in classes] - - sns.set(style="whitegrid") - - stage_order = list(dict.fromkeys(df["stage"])) - g = sns.FacetGrid( - df, - col="class", - col_wrap=3, - height=3, - aspect=1.2, - sharey=False, - col_order=classes_short #+["Other"], # preserve requested order - ) - g.map_dataframe( - sns.barplot, - x="stage", - y="count", - hue="pipeline", - hue_order=HUE_ORDER_2, - palette=PALETTE_2, - order=stage_order, - dodge=True, - errorbar=None - ) - - - for ax_idx, ax in enumerate(g.axes.flat): - class_idx = ax_idx - class_name = classes_short[class_idx] - - # remove x axis label - ax.set_xlabel("") - - for stage, class_counts in reference_stage_class_count.items(): - xpos = int(stage.split("_")[1]) - if stage == "stage_0": - ax.axhline(class_counts[class_name], ls="--", color="red") - else: - ax.axhline(class_counts[class_name], ls="--", color="black") - - - # g.add_legend() - - if g.legend is not None: - g.legend.remove() - - # build a combined legend below everything - handles, labels = g.axes[0].get_legend_handles_labels() - g.fig.legend( - handles, labels, - loc="lower center", - ncol=min(6, len(labels)), # split across columns - bbox_to_anchor=(0.5, -0.15) # push below the grid - ) - - # make space at bottom so legend isn’t cut off - g.fig.subplots_adjust(bottom=0.2) - - plt.subplots_adjust(top=0.88) - - # g.savefig("class_occurence_new.png") - - return g - - -def plot_class_occ_4_bar_chart(df): - metrics = ["class_occurrence"] - stages = ["stage_1", "stage_2", "stage_3"] - all_reference_values = {} - for metric in metrics: - for stage in stages: - value, nvalue, details = get_reference_value(df, metric, stage) - all_reference_values[metric] = { - "value": value, - "nvalue": nvalue, - "details": details - } - - reference_stage_class_count = get_reference_class_counts(df) - - # remove seed and reference pipeline - df = df[df["pipeline"] != "seed"] - df = df[df["pipeline"] != "json_b"] - df = df[df["pipeline"] != "rdf_b"] - df = df[df["pipeline"] != "text_b"] - df = df[df["pipeline"] != "reference"] - - # subplot_source_entity_integration(df) - - classes = ["http://kg.org/ontology/Film", "http://kg.org/ontology/Person", "http://kg.org/ontology/Company"] - - - return plot_class_occurence_new(df, reference_stage_class_count, classes) diff --git a/experiments/moviekg/src/moviekg/paper/helpers/ranking.py b/experiments/moviekg/src/moviekg/paper/helpers/ranking.py deleted file mode 100644 index ba9102a..0000000 --- a/experiments/moviekg/src/moviekg/paper/helpers/ranking.py +++ /dev/null @@ -1,119 +0,0 @@ -import pandas as pd -from collections import defaultdict -from typing import Any, Mapping, List, Dict - -from moviekg.config import OUTPUT_ROOT -from moviekg.paper.helpers.getter import ( - pipeline_stage_metric_dict, pipeline_name, metric_name, metric_value, - TABLE_DISPLAY_NAMES, - normalize_metric, normalize_min_best, normalize_max_best, - sta_fact_count, sta_denisity, sta_duration, #memory_peak is not considered - ref_kg_p, ref_source_entity_f1, - sem_disjoint_domain, sem_incorrect_relation_direction, sem_incorrect_relation_range, sem_incorrect_relation_domain, sem_incorrect_datatype, sem_incorrect_datatype_format -) - -type pipeline_agg = Mapping[pipeline_name, float] - -def agg_metrics(psmd: pipeline_stage_metric_dict, metric_names: List[metric_name]) -> pipeline_agg: - values_by_pipeline: Dict[pipeline_name, List[metric_value]] = defaultdict[pipeline_name, List[metric_value]](lambda: []) - for pipeline, stage_dict in psmd.items(): - if pipeline in ["reference", "seed"]: - continue - for stage, metric_dict in stage_dict.items(): - if stage not in ["stage_3"]: # only stage 3 is considered - continue - for metric_name in metric_names: - if metric_name in metric_dict: - values_by_pipeline[pipeline].append(metric_dict[metric_name]) - else: - print(f"pipeline: {pipeline}") - print(f"stage: {stage}") - print(f"metric_names: {metric_names}") - print(f"metric_dict: {metric_dict}") - raise ValueError(f"Metric {metric_name} not found in metric_names") - - res: pipeline_agg = defaultdict[pipeline_name, float](lambda: 0.0) - - for pipeline, values in values_by_pipeline.items(): - filtered_values = [value for value in values if value >= 0] - res[pipeline] = sum(filtered_values) / len(filtered_values) - print(pipeline) - print(" |\t".join(metric_names)) - print(" |\t".join([ str(value) for value in values_by_pipeline[pipeline]])) - print("="+str(res[pipeline])) - print("--------------------------------") - - return res - -def _rank_and_save2csv(weights: dict, outfile_stem: str, psmd: pipeline_stage_metric_dict, round_digits: int = 3) -> None: - - # psmd = normalize_metric(psmd, sta_fact_count.__name__, ["stage_3"], normalize_max_best) - psmd = normalize_metric(psmd, sta_denisity.__name__, ["stage_3"], normalize_max_best) - psmd = normalize_metric(psmd, sta_fact_count.__name__, ["stage_3"], normalize_max_best) - sta_metric_names = [sta_denisity.__name__+"_norm", sta_fact_count.__name__+"_norm"] - sta_agg = agg_metrics(psmd, sta_metric_names) - - sem_metric_names = [ - sem_disjoint_domain.__name__, sem_incorrect_relation_direction.__name__, - sem_incorrect_relation_range.__name__, sem_incorrect_relation_domain.__name__, - sem_incorrect_datatype.__name__, sem_incorrect_datatype_format.__name__] - sem_agg = agg_metrics(psmd, sem_metric_names) - - ref_metric_names = [ref_kg_p.__name__, ref_source_entity_f1.__name__+"_avg", "ref_selected_task_metric_avg"] - ref_agg = agg_metrics(psmd, ref_metric_names) - - psmd = normalize_metric(psmd, sta_duration.__name__+"_sum", ["stage_3"], normalize_min_best) - eff_metric_names = [sta_duration.__name__+"_sum_norm"] - eff_agg = agg_metrics(psmd, eff_metric_names) - - import json - json.dump(psmd, open(OUTPUT_ROOT / f"paper/{outfile_stem}_psmd.json", "w"), indent=4) - - df_rows = [] - - for pipeline, value in sem_agg.items(): - df_rows.append( - { - "pipeline": pipeline, - "semantic": round(value, round_digits), - "reference": round(ref_agg[pipeline], round_digits), - "size": round(sta_agg[pipeline], round_digits), - "efficiency": round(eff_agg[pipeline], round_digits) - } - ) - - - df = pd.DataFrame(df_rows) - - cols = ["size", "semantic", "reference", "efficiency"] - # Ensure we only use known columns; fill missing weights with 0.0 - w = pd.Series(weights).reindex(cols, fill_value=0.0) - - # Compute combined score - df = df[["pipeline"] + cols].copy() - df["combined"] = (df[cols] * w).sum(axis=1).round(round_digits) - - print(df.to_string()) - - # Sort & save (keep default index=True to match original behavior) - out = df[["pipeline", "combined"]].sort_values(by="combined", ascending=False) - out.to_csv(OUTPUT_ROOT / f"paper/{outfile_stem}.csv", sep="\t") - -# TODO cleanup -# def _rank_and_save(weights: dict, outfile_stem: str, df: pd.DataFrame, round_digits: int = 3) -> None: -# """ -# Compute weighted 'combined' score and save a TSV sorted by 'combined'. -# Uses the same behavior as your original functions (round to 3, keep default index in CSV). -# """ -# cols = ["size", "semantic", "reference", "efficiency"] -# # Ensure we only use known columns; fill missing weights with 0.0 -# w = pd.Series(weights).reindex(cols, fill_value=0.0) - -# # Compute combined score -# df = df[["pipeline"] + cols].copy() -# df["combined"] = (df[cols] * w).sum(axis=1).round(round_digits) - -# # Sort & save (keep default index=True to match original behavior) -# out = df[["pipeline", "combined"]].sort_values(by="combined", ascending=False) -# out.to_csv(OUTPUT_ROOT / f"paper/{outfile_stem}.csv", sep="\t") - diff --git a/experiments/moviekg/src/moviekg/paper/test_figtab.py b/experiments/moviekg/src/moviekg/paper/test_figtab.py deleted file mode 100644 index 6eb685c..0000000 --- a/experiments/moviekg/src/moviekg/paper/test_figtab.py +++ /dev/null @@ -1,779 +0,0 @@ -import json -import pandas as pd -from pathlib import Path -from collections import defaultdict - -from moviekg.config import OUTPUT_DIR, DATASET_SELECT -from moviekg.paper.helpers.agggregate import agg_duration_over_stages_per_pipeline -from moviekg.paper.helpers.getter import get_pipeline_stage_metric_dict, TABLE_DISPLAY_NAMES, apply_selected_updates -from moviekg.paper.helpers.helpers import load_metrics_from_file, plot_growth, plot_class_occ_4_bar_chart -from moviekg.paper.helpers.ranking import _rank_and_save2csv -from moviekg.paper.config import ( - name_mapping, METRIC_NAME_MAP, SEM_METRIC_SHORT_NAMES, - METRIC_NAME_INDEX_PRETTY, METRIC_NAME_MAP_PRETTY, SEM_METRIC_LONG_NAMES -) - - -# === Preamble === -if not OUTPUT_DIR: - raise ValueError("OUTPUT_DIR is not set") -if not DATASET_SELECT: - raise ValueError("DATASET_SELECT is not set") - -OUTPUT_ROOT = Path(OUTPUT_DIR) / DATASET_SELECT -(OUTPUT_ROOT / "paper").mkdir(parents=True, exist_ok=True) - -PIPLEINE_NAME_MAP = { - "json_rdf_text": "JRT", - "json_text_rdf": "JTR", - "rdf_json_text": "RJT", - "rdf_text_json": "RTJ", - "text_json_rdf": "TJR", - "text_rdf_json": "TRJ", - "json_a": "J_A", - "json_b": "J_B", - "json_c": "J_C", - "json_llm_mapping_v1": "J_C", - "json_baseA": "J_baseA", - "rdf_a": "R_A", - "rdf_b": "R_B", - "rdf_c": "R_C", - "rdf_llm_schema_align_v1": "R_C", - "text_a": "T_A", - "text_b": "T_B", - "text_c": "T_C", - "text_llm_triple_extract_v1": "T_C", - } - -def map_pipeline_name_pretty(pipeline_name): - return PIPLEINE_NAME_MAP.get(pipeline_name, pipeline_name) - -# === Helper Functions === - -def map_pipeline_name(pipeline_name): - return name_mapping.get(pipeline_name, pipeline_name) - - -def map_metric_name(metric_name): - return METRIC_NAME_MAP.get(metric_name, metric_name) - - -def add_REI_precision(metric_df): - # REI_fscore = 2 * (precision * recall) / (precision + recall) - source_entity_coverage_metric_soft = metric_df[metric_df["metric"] == "SourceEntityCoverageMetricSoft"] - - additional_rows = [] - for index, row in source_entity_coverage_metric_soft.iterrows(): - details = json.loads(row["details"]) - #"{""expected_entities_count"": 2758, ""found_entities_count"": 3099, ""overlapping_entities_count"": 53}" - - expected_entities_count = details["expected_entities_count"] - #found_entities_count = details["found_entities_count"] - overlapping_entities_count = details["overlapping_entities_count"] - - tp = overlapping_entities_count if overlapping_entities_count <= expected_entities_count else expected_entities_count - fp = overlapping_entities_count - tp if overlapping_entities_count > tp else 0 - precision = tp / (tp + fp) - - additional_rows.append( - {"pipeline": row["pipeline"], - "stage": row["stage"], - "metric": "REI_precision", - "aspect": "reference", - "normalized": precision, - "value": precision, - "details": row["details"]}) - - additional_df = pd.DataFrame(additional_rows) - return pd.concat([metric_df, additional_df]) - -def extract_class_occurence_df(df): - - classes = ["http://kg.org/ontology/Film", "http://kg.org/ontology/Person", "http://kg.org/ontology/Company"] - - - pipeline_stage_class_count = defaultdict(lambda: defaultdict(lambda: defaultdict(int))) - - # for each pipeline and stage - for pipeline in df["pipeline"].unique(): - for stage in df["stage"].unique(): - df_pipeline_stage = df[df["pipeline"] == pipeline] - df_pipeline_stage = df_pipeline_stage[df_pipeline_stage["stage"] == stage] - try: - details = json.loads(df_pipeline_stage["details"].values[0]) - class_counts = details["classes"] - for class_name, count in class_counts.items(): - if class_name not in classes: - class_name = "Other" - pipeline_stage_class_count[pipeline][stage][class_name] += count - except: - print(f"Error loading details for {pipeline} {stage}") - # print(df_pipeline_stage["details"].values[0]) - - rows = [] - for pipeline, stage_class_count in pipeline_stage_class_count.items(): - for stage, class_count in stage_class_count.items(): - for class_name, count in class_count.items(): - rows.append({"pipeline": pipeline, "stage": stage, "metric": class_name.split("/")[-1], "score": count}) - - return pd.DataFrame(rows) - - - -def map_metric_name_pretty(metric_name): - return METRIC_NAME_MAP_PRETTY.get(metric_name, metric_name) # TODO: remove this - -def get_statistics_df(df): - # only pipeline, stage, metric, normalized - - class_occurence_df = df[df["metric"] == "class_occurrence"] - class_count_df = extract_class_occurence_df(class_occurence_df) - - # print(class_count_df) - - df = df[df["aspect"] == "statistical"] - metircs = ["entity_count", "relation_count", "triple_count", "class_count", "duration", "loose_entity_count", "shallow_entity_count"] - df = df[df["metric"].isin(metircs)] - - df = df[["pipeline", "stage", "metric", "value"]] - df["score"] = df["value"].round(2) - - # union df and class_count_df - df = pd.concat([df, class_count_df]) - df[["pipeline"]] = df[["pipeline"]].map(map_pipeline_name) - - # rename metric to short name - df["metric"] = df["metric"].map(map_metric_name_pretty) - - # make each metric a column - df = df.pivot(index=["pipeline", "stage"], columns="metric", values="score") - df = df.reset_index() - - - return df - -def get_semantic_df(df): - # only pipeline, stage, metric, normalized - df = df[df["aspect"] == "semantic"] - df = df[["pipeline", "stage", "metric", "normalized"]] - - metrics = list(SEM_METRIC_SHORT_NAMES.keys()) - df = df[df["metric"].isin(metrics)] - - df["score"] = df["normalized"].round(2) - - # rename metric to short name - df["metric"] = df["metric"].map(map_metric_name_pretty) - - # make each metric a column - df = df.pivot(index=["pipeline", "stage"], columns="metric", values="score") - df = df.reset_index() - - return df - -def get_reference_df(df): - # TODO metric names and selection - # only pipeline, stage, metric, normalized - df = df[df["aspect"] == "reference"] - df = add_REI_precision(df) - - df = df[["pipeline", "stage", "metric", "normalized"]] - - metrics = [ - "ReferenceTripleAlignmentMetricSoftEV", - "ReferenceTripleAlignmentMetricSoftE", - "ReferenceTripleAlignmentMetric", - # "ReferenceClassCoverageMetric", - "SourceEntityCoverageMetric", - "SourceEntityCoverageMetricSoft", - "REI_precision", - "TE_ExpectedEntityLinkMetric", - "TE_ExpectedRelationLinkMetric", - "ER_EntityMatchMetric", - "ER_RelationMatchMetric", - ] - - df = df[df["metric"].isin(metrics)] - - df["score"] = df["normalized"].round(2) - - # rename metric to short name - df["metric"] = df["metric"].map(map_metric_name_pretty) - - # make each metric a column - df = df.pivot(index=["pipeline", "stage"], columns="metric", values="score") - df = df.reset_index() - - - return df - -# === Tests === - -def test_wide_table_smoth(): - """ - Stores all metrics in a wide table format. - """ - - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - - # replace pipeline name with name_mapping - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name) - - - # statistics_df - statistics_df = get_statistics_df(metric_df) - # semantic_df - semantic_df = get_semantic_df(metric_df) - # reference_df - reference_df = get_reference_df(metric_df) - - # join all of them on pipeline and stage - df = pd.merge(statistics_df, semantic_df, on=["pipeline", "stage"], how="left") - df = pd.merge(df, reference_df, on=["pipeline", "stage"], how="left") - - # colum order - df = df[["pipeline", "stage"] + [v for k, v in METRIC_NAME_INDEX_PRETTY]] - # print(df) - - df.to_csv(OUTPUT_ROOT / "paper/test_wide_table_smoth.csv", sep="\t") - - -def test_table_with_statistic_metrics(): - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - metrics = ["entity_count", "relation_count", "triple_count", "class_count", "loose_entity_count", "shallow_entity_count"] - - # filter for metrics - metric_df = metric_df[["pipeline", "stage", "metric", "value"]] - duration_df = agg_duration_over_stages_per_pipeline(metric_df) - duration_df = duration_df[["pipeline", "stage", "metric", "value"]] - metric_df = metric_df[metric_df["metric"].isin(metrics)] - - metric_df = pd.concat([metric_df, duration_df]) - - metric_df["metric"] = metric_df["metric"].map(map_metric_name) - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) - # only stage = stage_3 - metric_df = metric_df[metric_df["stage"] == "stage_3"] - - # Assuming your dataframe is called df - pivot_df = metric_df.pivot_table( - index=["pipeline", "stage"], # rows - columns="metric", # pivoted column - values="value" # values to fill - ).reset_index() - - # (Optional) Flatten the column index if needed - pivot_df.columns.name = None # remove "metric" header - - # column selection and order Pipeline FC EC RC TC Time - pivot_df = pivot_df[["pipeline", "FC", "EC", "RC", "TC", "SEC", "Time"]] - # save as TSV - output_path = OUTPUT_ROOT / "paper/test_tab_2_statistic_metrics.csv" - pivot_df.to_csv(output_path, sep="\t") - - -def test_table_with_semantic_metrics(): - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - # replace pipeline name with name_mapping - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) - # remove details colums - local_metric_df = metric_df.drop(columns=["details"]) - - # only stage = stage_1 and aspect = statistical - stage_3_df = local_metric_df[local_metric_df["stage"] == "stage_3"] - statistical_df = stage_3_df[stage_3_df["aspect"] == "semantic"] - # statistical_df["pipeline"] = statistical_df["pipeline"].map(map_pipeline_name_pretty) - - # print all available metric names - print(statistical_df["metric"].unique()) - - # rename metric to short name and remove metrics that are not in SEM_METRIC_SHORT_NAMES - statistical_df = statistical_df[statistical_df["metric"].isin(list(SEM_METRIC_SHORT_NAMES.keys()))] - statistical_df["metric"] = statistical_df["metric"].map(SEM_METRIC_SHORT_NAMES) - - # format normalized value to 2 decimal places - statistical_df["normalized"] = statistical_df["normalized"].round(3) - - # only stage = stage_3 - statistical_df = statistical_df[statistical_df["stage"] == "stage_3"] - - # make CSV with, x axis: pipeline, y axis: metric_name, cell: value - # Pivot the table: index=metric, columns=pipeline, values=value - pivot_df = statistical_df.pivot(index="metric", columns="pipeline", values="normalized") - # transpose the table - pivot_df = pivot_df.T - - # assume you have a dict SEM_METRIC_LONG_NAMES mapping short->long - long_name_row = {col: SEM_METRIC_LONG_NAMES.get(col, col) for col in pivot_df.columns} - pivot_df = pd.concat([pd.DataFrame([long_name_row], index=["metric_long_name"]), pivot_df]) - - # column selection and order pipeline 𝑂𝐷𝑇 𝑂𝐷 𝑂𝑅 𝑂𝑅𝐷 𝑂𝐿𝑇 𝑂𝐿𝐹 𝑂𝐴𝑣𝑔 - - output_path = OUTPUT_ROOT / "paper/test_tab_3_ssp_semantic_eval.csv" - pivot_df.to_csv(output_path, sep="\t") - -def test_table_with_matching_metrics(): - from moviekg.paper.helpers.getter import TABLE_DISPLAY_NAMES, get_pipeline_stage_metric_dict, ref_entity_matching_f1, ref_relation_matching_f1, ref_json_entity_matching_f1 - - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) - metrics = [metric for metric in list(TABLE_DISPLAY_NAMES.keys()) if metric in [ref_entity_matching_f1.__name__, ref_relation_matching_f1.__name__, ref_json_entity_matching_f1.__name__]] - - metric_dict = get_pipeline_stage_metric_dict(metric_df, metrics) - - df_rows = [] - for pipeline, stage_dict in metric_dict.items(): - for stage, metric_dict in stage_dict.items(): - rdf_em_f1 = metric_dict.get(ref_entity_matching_f1.__name__, -1) - json_em_f1 = metric_dict.get(ref_json_entity_matching_f1.__name__, -1) - em_f1 = -1 - if rdf_em_f1 != -1: - em_f1 = rdf_em_f1 - elif json_em_f1 != -1: - em_f1 = json_em_f1 - - rdf_rm_f1 = metric_dict.get(ref_relation_matching_f1.__name__, -1) - json_el_r = -1 # metric_dict.get(ref.__name__, -1) - rm_f1 = -1 - if rdf_rm_f1 != -1: - rm_f1 = rdf_rm_f1 - elif json_el_r != -1: - rm_f1 = json_el_r - - df_rows.append({"pipeline": pipeline, "stage": stage, "EM_f1": em_f1, "RM_f1": rm_f1}) - - # remove -1 rows - df_rows = [row for row in df_rows if row["EM_f1"] != -1 and row["RM_f1"] != -1] - - df = pd.DataFrame(df_rows) - # df = df.pivot(index=["pipeline", "stage"], columns="metric", values="value") - # df = df.reset_index() - output_path = OUTPUT_ROOT / "paper/test_tab_4_matching_metrics.csv" - df.to_csv(output_path, sep="\t") - -def test_table_with_matching_metrics_pr(): - from moviekg.paper.helpers.getter import ( - TABLE_DISPLAY_NAMES, get_pipeline_stage_metric_dict, - ref_entity_matching_f1, ref_entity_matching_p, ref_entity_matching_r, - ref_relation_matching_f1, ref_relation_matching_p, ref_relation_matching_r, - ref_json_entity_matching_f1, ref_json_entity_matching_p, ref_json_entity_matching_r - ) - - - - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) - metrics = [ - ref_entity_matching_p.__name__, ref_entity_matching_r.__name__, - ref_relation_matching_p.__name__, ref_relation_matching_r.__name__, - ref_json_entity_matching_p.__name__, ref_json_entity_matching_r.__name__ - ] - - psmd = get_pipeline_stage_metric_dict(metric_df, metrics) - - df_rows = [] - for pipeline, stage_dict in psmd.items(): - for stage, metric_dict in stage_dict.items(): - rdf_em_p = metric_dict.get(ref_entity_matching_p.__name__, -1) - rdf_em_r = metric_dict.get(ref_entity_matching_r.__name__, -1) - json_em_p = metric_dict.get(ref_json_entity_matching_p.__name__, -1) - json_em_r = metric_dict.get(ref_json_entity_matching_r.__name__, -1) - em_p = -1 - em_r = -1 - if rdf_em_p != -1: - em_p = rdf_em_p - em_r = rdf_em_r - elif json_em_p != -1: - em_p = json_em_p - em_r = json_em_r - - # print(json.dumps(metric_dict, indent=4)) - # print("--------------------------------") - - rdf_rm_p = metric_dict.get(ref_relation_matching_p.__name__, -1) - rdf_rm_r = metric_dict.get(ref_relation_matching_r.__name__, -1) - json_rm_p = metric_dict.get(ref_relation_matching_p.__name__, -1) - json_rm_r = metric_dict.get(ref_relation_matching_r.__name__, -1) - - rm_p = -1 - rm_r = -1 - if rdf_rm_p != -1: - rm_p = rdf_rm_p - rm_r = rdf_rm_r - elif json_rm_p != -1: - rm_p = json_rm_p - rm_r = json_rm_r - - df_rows.append({"pipeline": pipeline, "stage": stage, "EM_p": em_p, "EM_r": em_r, "RM_p": rm_p, "RM_r": rm_r}) - - # remove -1 rows - df_rows = [row for row in df_rows if row["EM_p"] != -1 and row["EM_r"] != -1 and row["RM_p"] != -1 and row["RM_r"] != -1] - - df = pd.DataFrame(df_rows) - # df = df.pivot(index=["pipeline", "stage"], columns="metric", values="value") - # df = df.reset_index() - output_path = OUTPUT_ROOT / "paper/test_tab_4_matching_metrics_pr.csv" - df.to_csv(output_path, sep="\t") - -def test_table_with_linking_metrics(): - from moviekg.paper.helpers.getter import TABLE_DISPLAY_NAMES, get_pipeline_stage_metric_dict, ref_entity_linking_r, ref_json_entity_linking_r - - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) - metrics = [metric for metric in list(TABLE_DISPLAY_NAMES.keys()) if metric in [ref_entity_linking_r.__name__, ref_json_entity_linking_r.__name__]] - - metric_dict = get_pipeline_stage_metric_dict(metric_df, metrics) - - df_rows = [] - for pipeline, stage_dict in metric_dict.items(): - for stage, metric_dict in stage_dict.items(): - rdf_el_r = metric_dict.get(ref_entity_linking_r.__name__, -1) - json_el_r = metric_dict.get(ref_json_entity_linking_r.__name__, -1) - el_r = -1 - if rdf_el_r != -1: - el_r = rdf_el_r - elif json_el_r != -1: - el_r = json_el_r - - df_rows.append({"pipeline": pipeline, "stage": stage, "EL_r": el_r}) - - # remove -1 rows - df_rows = [row for row in df_rows if row["EL_r"] != -1] - - df = pd.DataFrame(df_rows) - # df = df.pivot(index=["pipeline", "stage"], columns="metric", values="value") - # df = df.reset_index() - output_path = OUTPUT_ROOT / "paper/test_tab_5_linking_metrics.csv" - df.to_csv(output_path, sep="\t") - - -def test_table_6(): - """ - External KG R @inc (film) - EC (no Seed) REI @inc (film) - Pipeline | f1@1 f1@2 f1@3 p@3 | f1@1 f@2 f@3 - """ - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) - from moviekg.paper.helpers.getter import ( - get_pipeline_stage_metric_dict, ref_kg_f1, ref_kg_p, ref_kg_r, ref_source_entity_f1, ref_source_entity_p, ref_source_entity_r - ) - - metrics = [ - ref_kg_f1.__name__, ref_kg_p.__name__, ref_kg_r.__name__, ref_source_entity_f1.__name__, ref_source_entity_p.__name__, ref_source_entity_r.__name__ - ] - - psmd = get_pipeline_stage_metric_dict(metric_df, metrics) - # import json - # json.dump(psmd, open(OUTPUT_ROOT / "paper/test_tab_6_metrics.json", "w"), indent=4) - - rows = [] - - round_to = 2 - - for pipeline, stage_dict in psmd.items(): - if pipeline in ["reference", "seed"]: - continue - kg_p = [0, 0, 0] - kg_r = [0, 0, 0] - se_p = [0, 0, 0] - se_r = [0, 0, 0] - - - for stage, metric_dict in stage_dict.items(): - kg_p[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_kg_p.__name__, -1), round_to) - kg_r[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_kg_r.__name__, -1), round_to) - se_p[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_source_entity_p.__name__, -1), round_to) - se_r[int(stage.split("_")[1]) - 1] = round(metric_dict.get(ref_source_entity_r.__name__, -1), round_to) - - rows.append({ - "pipeline": pipeline, - "kg_p@1": kg_p[0], "kg_r@1": kg_r[0], "kg_p@2": kg_p[1], "kg_r@2": kg_r[1], "kg_p@3": kg_p[2], "kg_r@3": kg_r[2], - "se_p@1": se_p[0], "se_r@1": se_r[0], "se_p@2": se_p[1], "se_r@2": se_r[1], "se_p@3": se_p[2], "se_r@3": se_r[2]}) - - df = pd.DataFrame(rows) - output_path = OUTPUT_ROOT / "paper/test_tab_6_reference_alignment.csv" - df.to_csv(output_path, sep="\t") - -def test_table_with_reference_overlap_metrics(): - # "Pipeline Inc. P R F1 ∼P ∼F ∼F1" - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - - # replace pipeline name with name_mapping - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name) - - metric_names = ["ReferenceTripleAlignmentMetricSoftEV", "ReferenceTripleAlignmentMetricSoftE", "ReferenceTripleAlignmentMetric"] - names_map = { - "ReferenceTripleAlignmentMetricSoftEV": "soft_ev_", - "ReferenceTripleAlignmentMetricSoftE": "soft_e_", - "ReferenceTripleAlignmentMetric": "strict_", - } - - # filter for pipeline in pipeline_types - # global metric_df - # apply filter function - # only stage = stage_1 - metric_df = metric_df[metric_df["stage"] == "stage_3"] - metric_df = metric_df[metric_df["metric"].isin(metric_names)] - metric_df["metric"] = metric_df["metric"].map(names_map) - # order by stage and pipeline - # print(metric_df.pivot_table(index=["pipeline", "metric"], values="normalized", aggfunc="mean")) - - # extract precision, recall from details.json - metric_df["p"] = metric_df["details"].apply(lambda x: json.loads(x)["precision"] if "precision" in json.loads(x) else 0) - metric_df["r"] = metric_df["details"].apply(lambda x: json.loads(x)["recall"] if "recall" in json.loads(x) else 0) - # renmae value to f1 - metric_df["f1"] = metric_df["normalized"] - - # only pipline, metric, p, r, f1 - metric_df = metric_df[["pipeline", "metric", "p", "r", "f1"]] - - # result - df_wide = metric_df.pivot( - index="pipeline", - columns="metric", - values=["p", "r", "f1"] - ) - - # flatten MultiIndex columns - df_wide.columns = [f"{m if m!='' else ''}{k}" for k, m in df_wide.columns] - df_wide = df_wide.reset_index() - - # sort columns by name - df_wide = df_wide[sorted(df_wide.columns)] - # normalize values to 2 decimal places for all coluns except pipeline - df_wide.iloc[:, 1:] = df_wide.iloc[:, 1:].round(2) - - output_path = OUTPUT_ROOT / "paper/test_reference_alignment" - df_wide.to_csv(output_path, sep="\t") - -def test_figure_with_kg_growth(): - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - # remove reference stage_0 - metric_df["pipeline"] = metric_df["pipeline"].replace("json_b2", "json_b") - - metric_df = metric_df[metric_df["stage"] != "stage_0"] - - # filter for pipeline in pipeline_types - # global metric_df - # metric_df = filter_msp_and_reference(metric_df) - sorted_metric_df = metric_df.sort_values(by=["stage", "pipeline"]) - g = plot_growth(sorted_metric_df, metrics=["entity_count", "triple_count"], kind="bar") - g.fig.subplots_adjust(wspace=0.1) - # save as png - g.savefig(OUTPUT_ROOT / "paper/test_fig_both_growth.png") - - -def test_figure_with_entity_class_occurence(): - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - g = plot_class_occ_4_bar_chart(metric_df) - g.savefig(OUTPUT_ROOT / "paper/test_fig_msp_type_reference.png") - - -# Preset weight configs (kept exactly as used in your original code) -PRESETS = { - "equal": { - "size": 0.25, "semantic": 0.25, "reference": 0.25, "efficiency": 0.25 - }, - # Quantity-focused (your code used 0.5, 0.1, 0.1, 0.3) - "quantity_focused": { - "size": 0.5, "semantic": 0.1, "reference": 0.1, "efficiency": 0.3 - }, - # Quality-focused (your code used 0.0, 0.5, 0.5, 0.0) - "quality_focused": { - "size": 0.0, "semantic": 0.5, "reference": 0.5, "efficiency": 0.0 - }, - # Reference-alignment focused (your code used 0.0, 0.2, 0.8, 0.0) - "reference_alignment_focused": { - "size": 0.0, "semantic": 0.2, "reference": 0.8, "efficiency": 0.0 - }, - "efficiency_oriented": { - "size": 0.2, "semantic": 0.2, "reference": 0.2, "efficiency": 0.4 - }, -} - -psmd_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") -psmd = get_pipeline_stage_metric_dict(psmd_df, TABLE_DISPLAY_NAMES.keys()) -psmd = apply_selected_updates(psmd) - -# TODO cleanup -# norm_df, agg_df = aggregate_ranking_df() -# def test_rank_save_norm_df(): -# norm_df["normalized"] = norm_df["normalized"].round(2) -# # to format pipeline, metric_name1... metric_nameN, normalized -# wide = norm_df.pivot(index="pipeline", columns="metric", values="normalized").reset_index() -# wide.to_csv(OUTPUT_ROOT / "paper/test_rank_norm_df.csv", sep="\t") - -# c1 size, c2 sem, c3 ref, c4 eff -def test_rank_equal(): - # _rank_and_save(PRESETS["equal"], "test_rank_equal", agg_df) - _rank_and_save2csv(PRESETS["equal"], "test_rank_equal", psmd) - -def test_rank_quantity_focused(): - #_rank_and_save(PRESETS["quantity_focused"], "test_rank_quantity_focused", agg_df) - _rank_and_save2csv(PRESETS["quantity_focused"], "test_rank_quantity_focused", psmd) - -def test_rank_quality_focused(): - #_rank_and_save(PRESETS["quality_focused"], "test_rank_quality_focused", agg_df) - _rank_and_save2csv(PRESETS["quality_focused"], "test_rank_quality_focused", psmd) - -def test_rank_reference_alignment_focused(): - #_rank_and_save(PRESETS["reference_alignment_focused"], "test_rank_reference_alignment_focused", agg_df) - _rank_and_save2csv(PRESETS["reference_alignment_focused"], "test_rank_reference_alignment_focused", psmd) - -def test_rank_efficiency_oriented(): - #_rank_and_save(PRESETS["efficiency_oriented"], "test_rank_efficiency_oriented", agg_df) - _rank_and_save2csv(PRESETS["efficiency_oriented"], "test_rank_efficiency_oriented", psmd) - -def test_full_ranking_table(): - """ - for each rank table read it and then concatenate them into one table joining on the index - for example: - test_rank_equal.csv: - pipeline combined - 0 json_rdf_text 0.855084 - 1 json_text_rdf 0.867719 - 2 rdf_json_text 0.855081 - 3 rdf_text_json 0.867721 - 4 text_json_rdf 0.864522 - 5 text_rdf_json 0.864522 - test_rank_quantity_focused.csv: - pipeline combined - 0 rdf_json_text 0.950847 - 1 text_rdf_json 0.940847 - 2 json_text_rdf 0.93847 - 3 rdf_text_json 0.920847 - 4 json_rdf_text 0.910847 - 5 text_json_rdf 0.900847 - - the result should be: - pipeline combined - 0 json_rdf_text 0.855084 rdf_json_text_0.950847 - 1 json_text_rdf 0.867719 text_rdf_json_0.940847 - 2 rdf_json_text 0.855081 json_text_rdf_0.93847 - 3 rdf_text_json 0.867721 rdf_text_json_0.920847 - 4 text_json_rdf 0.864522 json_rdf_text_0.910847 - 5 text_rdf_json 0.864522 text_json_rdf_0.900847 - - rename the "combined" column for each to the name of the file - """ - - ranking_files = [ - "test_rank_equal.csv", - "test_rank_quantity_focused.csv", - "test_rank_quality_focused.csv", - "test_rank_reference_alignment_focused.csv", - "test_rank_efficiency_oriented.csv" - ] - - - ranking_files = [OUTPUT_ROOT / "paper" / file for file in ranking_files] - - # Base frame with fixed ranks 0..5 (top to bottom) - result = pd.DataFrame({"rank": range(15)}) - # result = pd.DataFrame() - - for file in ranking_files: - name = Path(file).stem # e.g., "test_rank_equal" - df = pd.read_csv(file, sep="\t") - # Ensure we have at least 6 rows; if more, keep top-6; if fewer, allow NaNs - # df = df.head(6).reset_index(drop=True) - - # pipeline name != reference and reset index - df = df[df["pipeline"] != "reference"] - df["pipeline"] = df["pipeline"].map(PIPLEINE_NAME_MAP) - df = df.reset_index(drop=True) - - - # Build two columns for this file: pipeline + score - sub = pd.DataFrame({ - "rank": df.index, - f"{name.split(".")[0]}_pipe": df["pipeline"], - f"{name.split(".")[0]}_score": df["combined"] - }) - - # Join on rank to keep rows aligned 0..5 - result = result.merge(sub, on="rank", how="left") - - # Make 'rank' the index if you prefer, or keep as a column - result = result.set_index("rank") - - result.to_csv(OUTPUT_ROOT / "paper/test_tab_7_full_ranking_table.csv", sep="\t") - -def test_new_ranking_table(): - """ - """ - from moviekg.paper.helpers.ranking import _rank_and_save3csv - df =_rank_and_save3csv("test_rank_new", psmd) - df["pipeline"] = df["pipeline"].map(PIPLEINE_NAME_MAP) - df.to_csv(OUTPUT_ROOT / "paper/test_tab_8_new_ranking_table.csv", sep="\t") - # metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - # metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) - # # metric_df = metric_df[metric_df["stage"] == "stage_3"] - # # metric_df = metric_df[metric_df["metric"].isin(TABLE_DISPLAY_NAMES.keys())] - # # metric_df = metric_df[metric_df["pipeline"] != "reference"] - # # metric_df = metric_df.reset_index(drop=True) - # # metric_df = metric_df.pivot(index="pipeline", columns="metric", values="normalized") - # # metric_df = metric_df.reset_index() - # metric_df.to_csv(OUTPUT_ROOT / "paper/test_tab_8_new_ranking_table.csv", sep="\t") - - -def test_new_quality_table(): - - metric_df = load_metrics_from_file(OUTPUT_ROOT / "all_metrics.csv") - metric_df["pipeline"] = metric_df["pipeline"].map(map_pipeline_name_pretty) - from moviekg.paper.helpers.getter import ( - get_pipeline_stage_metric_dict, - sta_entity_count, sta_fact_count, sta_type_count, sta_relation_count, sta_shallow_entity_count, sta_denisity, sta_duration, - ref_kg_f1, ref_kg_p, ref_kg_r, - ref_source_entity_f1, ref_source_entity_p, ref_source_entity_r, - ref_source_typed_entity_r, ref_source_typed_entity_p, ref_source_typed_entity_fn, - sem_disjoint_domain, sem_incorrect_relation_direction, sem_incorrect_relation_cardinality, sem_incorrect_relation_range, sem_incorrect_relation_domain, sem_incorrect_datatype, sem_incorrect_datatype_format, - ) - - metrics = [ - sta_entity_count.__name__, sta_fact_count.__name__, sta_type_count.__name__, sta_relation_count.__name__, sta_shallow_entity_count.__name__, sta_denisity.__name__, sta_duration.__name__, - ref_kg_f1.__name__, ref_kg_p.__name__, - ref_kg_r.__name__, ref_source_entity_f1.__name__, - ref_source_entity_p.__name__, ref_source_entity_r.__name__, - ref_source_typed_entity_r.__name__, ref_source_typed_entity_p.__name__, ref_source_typed_entity_fn.__name__, - sem_disjoint_domain.__name__, sem_incorrect_relation_direction.__name__, sem_incorrect_relation_cardinality.__name__, sem_incorrect_relation_range.__name__, sem_incorrect_relation_domain.__name__, sem_incorrect_datatype.__name__, sem_incorrect_datatype_format.__name__, - ] - - psmd = get_pipeline_stage_metric_dict(metric_df, metrics) - # import json - # json.dump(psmd, open(OUTPUT_ROOT / "paper/test_tab_6_metrics.json", "w"), indent=4) - - rows = [] - - round_to = 3 - - for pipeline, stage_dict in psmd.items(): - if pipeline in ["reference", "seed"]: - continue - - for stage, metric_dict in stage_dict.items(): - ec = round(metric_dict.get(sta_entity_count.__name__, -1), round_to) - kg_p = round(metric_dict.get(ref_kg_p.__name__, -1), round_to) - kg_r = round(metric_dict.get(ref_kg_r.__name__, -1), round_to) - se_p = round(metric_dict.get(ref_source_entity_p.__name__, -1), round_to) - se_r= round(metric_dict.get(ref_source_entity_r.__name__, -1), round_to) - ste_p = round(metric_dict.get(ref_source_typed_entity_p.__name__, -1), round_to) - ste_r = round(metric_dict.get(ref_source_typed_entity_r.__name__, -1), round_to) - ste_fn = round(metric_dict.get(ref_source_typed_entity_fn.__name__, -1), round_to) - o_dt = round(metric_dict.get(sem_disjoint_domain.__name__, -1), round_to) - o_d = round(metric_dict.get(sem_incorrect_relation_domain.__name__, -1), round_to) - o_r = round(metric_dict.get(sem_incorrect_relation_range.__name__, -1), round_to) - o_rd = round(metric_dict.get(sem_incorrect_relation_direction.__name__, -1), round_to) - o_lt = round(metric_dict.get(sem_incorrect_datatype.__name__, -1), round_to) - o_lf = round(metric_dict.get(sem_incorrect_datatype_format.__name__, -1), round_to) - - rows.append({ - "pipeline": pipeline, "stage": stage, - "EC": ec, - "kg_p": kg_p, "kg_r": kg_r, "se_p": se_p, "se_r": se_r, "ste_p": ste_p, "ste_r": ste_r, "ste_fn": ste_fn, - "O_DT": o_dt, "O_D": o_d, "O_R": o_r, "O_RD": o_rd, "O_LT": o_lt, "O_LF": o_lf - }) - - df = pd.DataFrame(rows) - df.to_csv(OUTPUT_ROOT / "paper/test_tab_9_new_quality_table.csv", sep="\t") \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/paper/test_ranksens.py b/experiments/moviekg/src/moviekg/paper/test_ranksens.py deleted file mode 100644 index e53f284..0000000 --- a/experiments/moviekg/src/moviekg/paper/test_ranksens.py +++ /dev/null @@ -1,162 +0,0 @@ -import re -import numpy as np -import pandas as pd -import matplotlib.pyplot as plt -from itertools import product - -# ========================= -# 1) Data -# ========================= -data = [ - ("T_C", 0.824, 0.367, 0.332), - ("R_A", 0.996, 0.993, 0.994), - ("TJR", 0.980, 0.980, 0.793), - ("RJT", 0.981, 0.967, 0.849), - ("TRJ", 0.980, 0.967, 0.808), - ("JRT", 0.982, 0.980, 0.838), - ("J_A", 0.938, 0.976, 0.988), - ("T_B", 0.893, 0.555, 0.580), - ("J_B", 0.968, 0.961, 0.788), - ("JTR", 0.981, 0.980, 0.806), - ("J_C", 0.993, 0.751, 0.851), - ("R_B", 0.993, 0.982, 0.962), - ("RTJ", 0.979, 0.967, 0.845), - ("R_C", 0.996, 0.984, 0.993), - ("T_A", 0.986, 0.526, 0.590), -] -df = pd.DataFrame(data, columns=["pipeline", "semantic", "correctness", "coverage"]) - -# ========================= -# 2) Define cohorts -# ========================= -# Single-source pipelines: "R_A", "J_B", "T_C", etc. -single_re = re.compile(r"^[RJT]_[A-Z]$") - -df["is_single"] = df["pipeline"].apply(lambda s: bool(single_re.match(s))) -df["source_type"] = df["pipeline"].apply(lambda s: s[0]) # 'R', 'J', 'T' - -single_df = df[df["is_single"]].copy() -multi_df = df[~df["is_single"]].copy() # e.g., "TJR", "RJT", ... - -# Cohort dict: RDF-only, JSON-only, TEXT-only, and Multi-source -cohorts = { - "RDF-only (R_*)": single_df[single_df["source_type"] == "R"].copy(), - "JSON-only (J_*)": single_df[single_df["source_type"] == "J"].copy(), - "Text-only (T_*)": single_df[single_df["source_type"] == "T"].copy(), - "Multi-source (no underscore)": multi_df.copy(), -} - -# ========================= -# 3) Weight grid on simplex -# ========================= -# Weights are (w_sem, w_cor, w_cov) with w_sum=1 and w_i>=0 -STEP = 0.05 # set to 0.1 for fewer points -vals = np.round(np.arange(0, 1 + 1e-9, STEP), 10) - -weights = [] -for w in product(vals, repeat=3): - if abs(sum(w) - 1.0) < 1e-9: - weights.append(w) -weights = np.array(weights) # (N, 3) -print(f"Weight grid: step={STEP}, N={len(weights)} points") - -# ========================= -# 4) Sensitivity computation -# ========================= -def sensitivity_summary(cohort_df: pd.DataFrame, weights: np.ndarray) -> pd.DataFrame: - """ - Returns per-pipeline: - - wins: how many weight points where it ranks #1 - - win_fraction - - avg_rank - - avg_score (mean across weights) - """ - if cohort_df.empty: - return pd.DataFrame() - - M = cohort_df[["semantic", "correctness", "coverage"]].to_numpy() # (m,3) - scores = weights @ M.T # (N,m) - - # winner counts - winner_idx = np.argmax(scores, axis=1) - winners = cohort_df["pipeline"].iloc[winner_idx].to_numpy() - win_counts = pd.Series(winners).value_counts().reindex(cohort_df["pipeline"]).fillna(0).astype(int) - - # rank matrix: rank 1 = best - order = scores.argsort(axis=1)[:, ::-1] - rank_matrix = np.empty_like(order) - for i in range(order.shape[0]): - rank_matrix[i, order[i]] = np.arange(1, M.shape[0] + 1) - - summary = pd.DataFrame({ - "wins": win_counts.values, - "win_fraction": (win_counts.values / len(weights)), - "avg_rank": rank_matrix.mean(axis=0), - "avg_score": scores.mean(axis=0), - }, index=cohort_df["pipeline"].values) - - summary = summary.sort_values(["win_fraction", "avg_rank"], ascending=[False, True]) - return summary - -all_summaries = {name: sensitivity_summary(cdf, weights) for name, cdf in cohorts.items()} - -# Print summaries -for name, summ in all_summaries.items(): - print("\n" + "=" * 80) - print(name) - if summ.empty: - print("(empty cohort)") - else: - print(summ) - -# ========================= -# 5) Plots (VLDB-friendly) -# ========================= -# A) Win-fraction bars for each cohort -# for name, summ in all_summaries.items(): -# if summ.empty: -# continue -# plt.figure(figsize=(9, 3.8)) -# plt.bar(summ.index, summ["win_fraction"].values) -# plt.xticks(rotation=45, ha="right") -# plt.ylabel("Win fraction (#1 over weight grid)") -# plt.title(f"{name} — winner sensitivity (step={STEP})") -# plt.tight_layout() -# plt.show() - -# B) Average-rank bars for each cohort -# for name, summ in all_summaries.items(): -# if summ.empty: -# continue -# plt.figure(figsize=(9, 3.8)) -# plt.bar(summ.index, summ["avg_rank"].values) -# plt.xticks(rotation=45, ha="right") -# plt.ylabel("Average rank (lower is better)") -# plt.title(f"{name} — average rank over weight grid") -# plt.tight_layout() -# plt.show() - -# ========================= -# 6) Optional: a compact “paper table” per cohort -# ========================= -paper_tables = {} -for name, summ in all_summaries.items(): - if summ.empty: - continue - paper_tables[name] = summ[["win_fraction", "avg_rank"]].copy() - -print("\n" + "=" * 80) -print("Compact paper tables (win_fraction, avg_rank):") -for name, t in paper_tables.items(): - print("\n---", name, "---") - print(t) - -# ========================= -# 7) Optional: export to CSV (uncomment if you want files) -# ========================= -# for name, summ in all_summaries.items(): -# if summ.empty: -# continue -# safe_name = re.sub(r"[^A-Za-z0-9]+", "_", name).strip("_") -# summ.to_csv(f"sensitivity_{safe_name}.csv") -# print("Wrote CSV files.") \ No newline at end of file diff --git a/experiments/moviekg/src/moviekg/pipelines/helpers.py b/experiments/moviekg/src/moviekg/pipelines/helpers.py index fab8ee3..d47c2e5 100644 --- a/experiments/moviekg/src/moviekg/pipelines/helpers.py +++ b/experiments/moviekg/src/moviekg/pipelines/helpers.py @@ -7,7 +7,7 @@ from kgpipe.generation.loaders import build_from_conf from kgpipe.datasets.multipart_multisource import Dataset -from moviekg.datasets.pipe_out import PipeOut, StageOut +from kgpipe.io.pipe_out import PipeOut, StageOut from moviekg.config import dataset, catalog @@ -70,7 +70,12 @@ def run_helper( tmp_dir = stage_dir / "tmp" tmp_dir.mkdir(parents=True, exist_ok=True) - pipeline = build_from_conf(pipeline_conf, target_data, tmp_dir.as_posix()) + pipeline = build_from_conf( + name=pipeline_name, + conf=pipeline_conf, + target_data=target_data, + data_dir=tmp_dir.as_posix(), + ) stage_dir.mkdir(parents=True, exist_ok=True) diff --git a/mkdocs.yml b/mkdocs.yml index 7a41041..f1400c0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,6 +32,7 @@ site_dir: site nav: - Home: index.md - Quickstart: quickstart.md + - KGI-Bench (benchmark site): /kgibench/ - Concepts: - Tasks: tasks.md - Pipelines: pipelines.md @@ -48,5 +49,5 @@ nav: - Experiments: - Reproduce MovieKG: reproduce.md - Other: - - Migration: migration.md + - Adoption (integrating existing pipelines): adoption.md - View/UI: view.md diff --git a/src/kgpipe/cli/eval_new.py b/src/kgpipe/cli/eval_new.py index 3c9b442..a86f141 100644 --- a/src/kgpipe/cli/eval_new.py +++ b/src/kgpipe/cli/eval_new.py @@ -9,6 +9,8 @@ from kgpipe_eval.metrics.statistics import CountMetric from kgpipe_eval.metrics.duplicates import DuplicateMetric from kgpipe_eval.metrics.entity_alignment import EntityAlignmentMetric +from kgpipe_eval.metrics.triple_alignment import TripleAlignmentMetric +from kgpipe_eval.metrics.consistency_violations import DisjointDomainMetric, DomainMetric, RangeMetric, RelationDirectionMetric, DatatypeMetric, DatatypeFormatMetric from kgpipe_eval.utils.kg_utils import KgManager from kgpipe_eval.utils.metric_utils import MeasurementKey, parse_eval_results, write_eval_csv from kgpipe_eval.config.manager import load_metric_configs, write_default_config_yaml @@ -68,6 +70,13 @@ def _available_metric_instances() -> dict[str, Any]: "CountMetric": CountMetric(), "DuplicateMetric": DuplicateMetric(), "EntityAlignmentMetric": EntityAlignmentMetric(), + "TripleAlignmentMetric": TripleAlignmentMetric(), + "DisjointDomainMetric": DisjointDomainMetric(), + "DomainMetric": DomainMetric(), + "RangeMetric": RangeMetric(), + "RelationDirectionMetric": RelationDirectionMetric(), + "DatatypeMetric": DatatypeMetric(), + "DatatypeFormatMetric": DatatypeFormatMetric(), } def _normalize_key(k: str) -> str: @@ -78,6 +87,35 @@ def _metric_key(metric: Any) -> str: return getattr(metric, "key", metric.__class__.__name__) +def _metric_description(metric: Any) -> str: + cls = metric.__class__ + desc = getattr(cls, "description", None) + if desc: + return str(desc).strip() + if cls.__doc__: + return cls.__doc__.strip().split("\n")[0] + compute_doc = cls.compute.__doc__ + if compute_doc: + return compute_doc.strip().split("\n")[0] + return "—" + + +def _render_available_metrics_table() -> None: + metrics = _available_metric_instances() + table = Table(title="Available metrics (eval-new)") + table.add_column("Name", style="cyan") + table.add_column("Description", style="green") + + for name in sorted(metrics.keys()): + table.add_row(name, _metric_description(metrics[name])) + + console.print(table) + console.print( + f"[dim]{len(metrics)} metric(s). " + "Pass one or more with `eval-new run -m `.[/dim]" + ) + + def _build_confs_for_selected_metrics( selected_metric_instances: list[Any], loaded_confs: dict[str, Any], @@ -167,6 +205,14 @@ def eval_new_cmd() -> None: """ +@eval_new_cmd.command(name="list") +def list_metrics_cmd() -> None: + """ + List all metrics available to `eval-new run`. + """ + _render_available_metrics_table() + + @eval_new_cmd.command(name="run") @click.argument("kg_paths", nargs=-1, type=click.Path(exists=True)) @click.option( diff --git a/src/kgpipe/io/__init__.py b/src/kgpipe/io/__init__.py new file mode 100644 index 0000000..fe16459 --- /dev/null +++ b/src/kgpipe/io/__init__.py @@ -0,0 +1,2 @@ +__all__ = [] + diff --git a/src/kgpipe/io/pipe_out.py b/src/kgpipe/io/pipe_out.py new file mode 100644 index 0000000..e19bcd5 --- /dev/null +++ b/src/kgpipe/io/pipe_out.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from pathlib import Path +from typing import List, Optional + +from pydantic import BaseModel + +from kgpipe.common.models import KgPipePlan, KgStageReport + + +class TaskOut(BaseModel): + """ + Output artifacts produced by a single task within a stage. + """ + + task_name: str + output: List[Path] + + +class StageOut(BaseModel): + """ + Output artifacts for one incremental stage. + """ + + root: Path + stage_name: str + tasks: List[TaskOut] + resultKG: Optional[Path] = None + plan: Optional[KgPipePlan] = None + report: KgStageReport + + @property + def stage_index(self) -> int: + """ + Extract stage number from `stage_` directory name. + """ + return int(self.stage_name.split("_", 1)[1]) + + +class PipeOut(BaseModel): + """ + Output artifacts for a full incremental pipeline run directory containing stage_* subdirs. + """ + + root: Path + pipeline_name: str + stages: List[StageOut] + resultKG: Optional[Path] = None + + +def _stage_paths(run_dir: Path) -> list[Path]: + stage_paths = [p for p in run_dir.iterdir() if p.is_dir() and p.name.startswith("stage_")] + stage_paths.sort(key=lambda p: int(p.name.split("_", 1)[1])) + return stage_paths + + +def _resolve_stage_result_kg(stage_dir: Path) -> Path: + """ + Prefer `result_eval.nt` (evaluation-ready), fallback to `result.nt`. + """ + candidates = [ + # stage_dir / "result_eval.nt", + stage_dir / "result.nt", + ] + for c in candidates: + if c.exists(): + return c + # Keep the legacy default for downstream tools that expect result.nt even if not created yet. + return stage_dir / "result.nt" + + +def load_stage_out(stage_dir: Path) -> StageOut: + """ + Load stage outputs from a `stage_` directory produced by KGpipe incremental runs. + """ + stage_name = stage_dir.name + + plan_path = stage_dir / "exec-plan.json" + report_path = stage_dir / "exec-report.json" + + if not plan_path.exists(): + raise FileNotFoundError(f"Missing exec plan: {plan_path}") + if not report_path.exists(): + raise FileNotFoundError(f"Missing exec report: {report_path}") + + stage_plan = KgPipePlan.model_validate_json(plan_path.read_text()) + + stage_tasks: list[TaskOut] = [] + for step in stage_plan.steps: + stage_tasks.append( + TaskOut( + task_name=step.task, + output=[stage_dir / f"{output.path}" for output in step.output], + ) + ) + + stage_report = KgStageReport.model_validate_json(report_path.read_text()) + + return StageOut( + root=stage_dir, + stage_name=stage_name, + tasks=stage_tasks, + resultKG=_resolve_stage_result_kg(stage_dir), + plan=stage_plan, + report=stage_report, + ) + + +def load_pipe_out(run_dir: Path) -> PipeOut: + """ + Load a pipeline run output directory that contains `stage_*` directories. + """ + run_dir = Path(run_dir) + stages = [load_stage_out(p) for p in _stage_paths(run_dir)] + + return PipeOut( + root=run_dir, + pipeline_name=run_dir.name, + stages=stages, + resultKG=_resolve_stage_result_kg(run_dir) if (run_dir / "result.nt").exists() else (run_dir / "result.nt"), + ) + diff --git a/src/kgpipe_eval/metrics/consistency_violations.py b/src/kgpipe_eval/metrics/consistency_violations.py index 6e0ddd4..7bbc1ae 100644 --- a/src/kgpipe_eval/metrics/consistency_violations.py +++ b/src/kgpipe_eval/metrics/consistency_violations.py @@ -1,14 +1,50 @@ -from kgpipe_eval.api import Metric +from kgpipe_eval.api import Metric, MetricResult, Measurement from pydantic import BaseModel, model_validator, ConfigDict from kgpipe.common import KG from pathlib import Path from kgpipe_eval.utils.kg_utils import TripleGraph +from typing import Dict, Set, Optional + +from rdflib import URIRef, RDF, Literal, Graph, XSD +from rdflib.query import Result, ResultRow + +from kgcore.api.ontology import Ontology, OntologyUtil +from tqdm import tqdm + +def get_ontology_graph(ontology_path: Optional[Path], kg: KG) -> Graph: + if ontology_path is not None: + return Graph().parse(ontology_path) + elif kg is not None: + return kg.get_ontology_graph() + + +def enrich_type_information(graph: Graph, ontology: Ontology, type_property: URIRef = RDF.type) -> Graph: + type_dict = {} + + new_graph = Graph() + + for s, p, o in graph: + domain, range = ontology.get_domain_range(str(p)) + if domain and isinstance(s, URIRef): + if str(s) not in type_dict: + type_dict[str(s)] = [] + type_dict[str(s)].append(str(domain)) + if range and isinstance(o, URIRef): + if str(o) not in type_dict: + type_dict[str(o)] = [] + type_dict[str(o)].append(str(range)) + new_graph.add((s, p, o)) + + for uri, types in type_dict.items(): + for type in types: + new_graph.add((URIRef(uri), type_property, URIRef(type))) + return new_graph class ConsistencyViolationsConfig(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) - reference_kg: KG - ontology_path: Path + reference_kg: Optional[KG] = None + ontology_path: Optional[Path] = None @model_validator(mode="after") def _require_reference_kg_or_ontology_path(self): @@ -18,27 +54,554 @@ def _require_reference_kg_or_ontology_path(self): class DisjointDomainMetric(Metric): def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): - pass + """Compute disjoint domain score.""" + + raw_graph: Graph = kg.get_graph() + ontology_graph: Graph = get_ontology_graph(config.ontology_path, config.reference_kg) + ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) + graph = enrich_type_information(raw_graph, ontology) + + for s, p, o in ontology_graph.triples((None, None, None)): + graph.add((s, p, o)) + + # Get all disjoint domains + disjoint_domains_qr: Result = graph.query( + """ + SELECT DISTINCT ?subject + WHERE { + ?subject a ?disjointDomain1 . + ?subject a ?disjointDomain2 . + ?disjointDomain1 owl:disjointWith ?disjointDomain2 . + } + """ + ) + subjects_with_disjoint_domains = set([row["subject"] for row in disjoint_domains_qr if isinstance(row, ResultRow)]) + + subjects = set([str(s) for s in graph.subjects()]) + + return MetricResult( + metric=self, + measurements=[ + Measurement(name="subjects_with_disjoint_domains", value=len(subjects_with_disjoint_domains), unit="number"), + Measurement(name="subjects", value=len(subjects), unit="number"), + Measurement(name="normalized_score", value=1.0 - (len(subjects_with_disjoint_domains) / len(subjects)), unit="ratio"), + ], + summary=f"Number of subjects with disjoint domains: {len(subjects_with_disjoint_domains)}", + ) class DomainMetric(Metric): def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): - pass + """Compute incorrect relation domain score. + + TODO: check if this is correct for increment eval if namespace changes to former generic namespace not seed + """ + + raw_graph: Graph = kg.get_graph() + ontology_graph: Graph = get_ontology_graph(config.ontology_path, config.reference_kg) + ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) + graph = enrich_type_information(raw_graph, ontology) + + # disjoint class by class + disjoint_class_by_class : Dict[str, Set[str]] = {} + for class_ in ontology.classes: + if class_.disjointWith is not None: + disjoint_class_by_class[class_.uri] = class_.disjointWith + else: + disjoint_class_by_class[class_.uri] = set() + + + def is_subject_type(o, type): + # print(o, type) + if isinstance(o, URIRef): + types = [str(t) for _, _, t in graph.triples((o, RDF.type, None))] + return type in types and not any(str(other_type) in disjoint_class_by_class.get(str(type), set()) for other_type in types) + elif isinstance(o, Literal): + return o.datatype == type + else: + return False + + domain_by_property = {} + for property in ontology.properties: + if property.domain is not None: + domain_by_property[property.uri] = property.domain.uri + else: + print(f"Property {property.uri} has no domain") + domain_by_property[property.uri] = "TODO" + + incorrect_relation_domain = 0 + correct_relation_domain = 0 + + for s, p, o in graph.triples((None, None, None)): + if str(p) in domain_by_property: + if is_subject_type(s, domain_by_property[str(p)]): + correct_relation_domain += 1 + else: + incorrect_relation_domain += 1 + + if incorrect_relation_domain + correct_relation_domain > 0: + normalized_score = 1.0 - (incorrect_relation_domain / (incorrect_relation_domain + correct_relation_domain)) + else: + normalized_score = 0.0 + + return MetricResult( + metric=self, + measurements=[ + Measurement(name="incorrect_relation_domain", value=incorrect_relation_domain, unit="number"), + Measurement(name="correct_relation_domain", value=correct_relation_domain, unit="number"), + Measurement(name="normalized_score", value=normalized_score, unit="ratio"), + ], + summary=f"Number of incorrect relation domain: {incorrect_relation_domain}", + # name=self.name, + # value=incorrect_relation_domain, + # normalized_score=normalized_score, + # details={"incorrect_relation_domain": incorrect_relation_domain, "correct_relation_domain": correct_relation_domain}, + # aspect=self.aspect + ) class RangeMetric(Metric): + def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): - pass + """Compute incorrect relation range score.""" + + raw_graph: Graph = kg.get_graph() + ontology_graph: Graph = get_ontology_graph(config.ontology_path, config.reference_kg) + ontology : Ontology= OntologyUtil.load_ontology_from_graph(ontology_graph) + graph = enrich_type_information(raw_graph, ontology) + + # disjoint class by class + disjoint_class_by_class : Dict[str, Set[str]] = {} + for class_ in ontology.classes: + if class_.disjointWith is not None: + disjoint_class_by_class[class_.uri] = class_.disjointWith + else: + disjoint_class_by_class[class_.uri] = set() + + def is_object_type(o, type): + # print(o, type) + if isinstance(o, URIRef): + types = [str(t) for s, p, t in graph.triples((o, RDF.type, None))] + # if str(type) not in types: + # print(f"Incorrect relation range {types} of {o} for property {p} with range {types}") + return str(type) in types and not any(str(other_type) in disjoint_class_by_class.get(str(type), set()) for other_type in types) + elif isinstance(o, Literal): + datatype = o.datatype + if not datatype: + datatype = str(XSD.string) + return str(datatype) == str(type) + else: + return False + + + range_by_property = {} + for property in ontology.properties: + if property.range is not None: + range_by_property[property.uri] = property.range.uri + else: + # print(f"Property {property.uri} has no range") + range_by_property[property.uri] = None + + incorrect_relation_range = 0 + correct_relation_range = 0 + + for s, p, o in graph.triples((None, None, None)): + if str(p) in range_by_property: + if is_object_type(o, range_by_property[str(p)]): + correct_relation_range += 1 + else: + # print(f"Incorrect relation range {o if isinstance(o, URIRef) else o.datatype} for property {p} with range {range_by_property[str(p)]}") + incorrect_relation_range += 1 + + normalized_score = 1.0 - (incorrect_relation_range / (incorrect_relation_range + correct_relation_range)) if incorrect_relation_range + correct_relation_range > 0 else 1.0 + """Compute incorrect relation range score.""" + return MetricResult( + metric=self, + measurements=[ + Measurement(name="incorrect_relation_range", value=incorrect_relation_range, unit="number"), + Measurement(name="correct_relation_range", value=correct_relation_range, unit="number"), + Measurement(name="normalized_score", value=normalized_score, unit="ratio"), + ], + summary=f"Number of incorrect relation range: {incorrect_relation_range}", + # name=self.name, + # value=incorrect_relation_range, + # normalized_score=normalized_score, + # details={"incorrect_relation_range": incorrect_relation_range, "correct_relation_range": correct_relation_range}, + # aspect=self.aspect + ) class RelationDirectionMetric(Metric): def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): - pass + """Compute incorrect relation direction score.""" + + raw_graph: Graph = kg.get_graph() + ontology_graph: Graph = get_ontology_graph(config.ontology_path, config.reference_kg) + ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) + graph = enrich_type_information(raw_graph, ontology) + + if len(ontology_graph) == 0: + ontology_graph = graph + print(f"INFO: ontology_graph is empty, using graph instead") + + # TODO use ontology implementation from framework + predicate_defs_sr = ontology_graph.query( + """ + SELECT DISTINCT ?predicate ?domain ?range + WHERE { + ?predicate rdfs:domain ?domain . + ?predicate rdfs:range ?range . + } + """ + ) + + # def check_type(uri, type): + # result = graph.query( + # """ + # SELECT ?uri + # WHERE { + # ?uri a ?type . + # } + # """, + # initBindings={"uri": uri, "type": type} + # ) + # return len(result) > 0 + + predicate_defs = {} + for row in predicate_defs_sr: + predicate_defs[str(row["predicate"])] = (str(row["domain"]), str(row["range"])) + + incorrect_relation_direction = 0 + correct_relation_direction = 0 + + entity_types = {} + for s, p, o in graph.triples((None, RDF.type, None)): + if str(s) not in entity_types: + entity_types[str(s)] = [] + entity_types[str(s)].append(str(o)) + + for s, p, o in tqdm(graph, desc="Checking relation direction"): + if str(s) not in entity_types: + continue + if str(p) in predicate_defs: + domain, range = predicate_defs[str(p)] + + if isinstance(o, URIRef): + if not str(s) in entity_types: + # print(f"Skipping s {s} because it is not in entity_types") + continue + if not str(o) in entity_types: + # print(f"Skipping o {o} because it is not in entity_types") + continue + if domain in entity_types[str(s)] and range in entity_types[str(o)]: + correct_relation_direction += 1 + if domain in entity_types[str(o)] and range in entity_types[str(s)]: + incorrect_relation_direction += 1 + + # print("incorrect_relation_direction", incorrect_relation_direction) + # print("correct_relation_direction", correct_relation_direction) + + if incorrect_relation_direction + correct_relation_direction > 0: + normalized_score = incorrect_relation_direction / (incorrect_relation_direction + correct_relation_direction) + normalized_score = 1.0 - normalized_score + else: + normalized_score = 0.0 + + return MetricResult( + metric=self, + measurements=[ + Measurement(name="incorrect_relation_direction", value=incorrect_relation_direction, unit="number"), + Measurement(name="correct_relation_direction", value=correct_relation_direction, unit="number"), + Measurement(name="normalized_score", value=normalized_score, unit="ratio"), + ], + summary=f"Number of incorrect relation direction: {incorrect_relation_direction}", + # name=self.name, + # value=incorrect_relation_direction, + # normalized_score=normalized_score, + # details={ + # "incorrect_relation_direction": incorrect_relation_direction, + # "correct_relation_direction": correct_relation_direction, + # "possible_relations": predicate_defs, + # "size_ontology_graph": len(ontology_graph) + # }, + # aspect=self.aspect + ) class DatatypeMetric(Metric): def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): - pass + """Compute incorrect datatype score.""" + + raw_graph: Graph = kg.get_graph() + ontology_graph: Graph = get_ontology_graph(config.ontology_path, config.reference_kg) + ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) + graph = enrich_type_information(raw_graph, ontology) + + def is_object_type(o, type): + # print(o, type) + if isinstance(o, URIRef): + types = [str(t) for s, p, t in graph.triples((o, RDF.type, None))] + # if str(type) not in types: + # print(f"Incorrect relation range {types} of {o} for property {p} with range {types}") + return str(type) in types + elif isinstance(o, Literal): + datatype = o.datatype + if not datatype: + datatype = str(XSD.string) + return str(datatype) == str(type) + else: + return False + + # def is_object_type(o, type): + # # print(o, type) + # if isinstance(o, URIRef): + # types = [str(t) for s, p, t in graph.triples((o, RDF.type, None))] + # return type in types + # elif isinstance(o, Literal): + # return str(o.datatype) == type + # else: + # return False + + range_by_property = {} + for property in ontology.properties: + if property.range is not None: + range_by_property[property.uri] = property.range.uri + else: + print(f"Property {property.uri} has no range") + range_by_property[property.uri] = "TODO" + + incorrect_datatype = 0 + correct_datatype = 0 + + for s, p, o in graph.triples((None, None, None)): + if str(p) in range_by_property: + if isinstance(o, Literal): + if not str(p) in range_by_property or is_object_type(o, range_by_property[str(p)]): + correct_datatype += 1 + else: + incorrect_datatype += 1 + # print(f"Incorrect datatype {o.datatype} for property {p} with range {range_by_property[str(p)]}") + + normalized_score = 1.0 - (incorrect_datatype / (incorrect_datatype + correct_datatype)) if incorrect_datatype + correct_datatype > 0 else 0.0 + + return MetricResult( + metric=self, + measurements=[ + Measurement(name="incorrect_datatype", value=incorrect_datatype, unit="number"), + Measurement(name="correct_datatype", value=correct_datatype, unit="number"), + Measurement(name="normalized_score", value=normalized_score, unit="ratio"), + ], + summary=f"Number of incorrect datatype: {incorrect_datatype}", + # name=self.name, + # value=incorrect_datatype, + # normalized_score=1.0 - (incorrect_datatype / (incorrect_datatype + correct_datatype)) if incorrect_datatype + correct_datatype > 0 else 0.0, + # details={"incorrect_datatype": incorrect_datatype, "correct_datatype": correct_datatype}, + # aspect=self.aspect + ) class DatatypeFormatMetric(Metric): def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): - pass + """Compute incorrect datatype format score.""" + + from kgpipe.evaluation.aspects.func.datatype_validator import validate_datatype + + raw_graph: Graph = kg.get_graph() + ontology_graph: Graph = get_ontology_graph(config.ontology_path, config.reference_kg) + ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) + graph = enrich_type_information(raw_graph, ontology) + + def is_object_type(o, type): + # print(o, type) + if isinstance(o, URIRef): + types = [str(t) for s, p, t in graph.triples((o, RDF.type, None))] + return type in types + elif isinstance(o, Literal): + return str(o.datatype) == type + else: + return False + + range_by_property = {} + for property in ontology.properties: + if property.range is not None: + range_by_property[property.uri] = property.range.uri + else: + print(f"Property {property.uri} has no range") + range_by_property[property.uri] = "TODO" + + incorrect_datatype = 0 + correct_datatype = 0 + + for s, p, o in graph.triples((None, None, None)): + if str(p) in range_by_property: + if isinstance(o, Literal): + if str(p) in range_by_property: + if validate_datatype(str(o), range_by_property[str(p)]): + # print(f"Correct datatype {o.datatype} for property {p} and value {o} with range {range_by_property[str(p)]}") + correct_datatype += 1 + else: + # print(f"Incorrect datatype {p} \'{o}\' {range_by_property[str(p)]}") + incorrect_datatype += 1 + else: + print(f"Property {p} has no range") + # if not str(p) in range_by_property: + # print(f"Property {p} has no range") + # # or validate_datatype(str(o), range_by_property[str(p)]): + # # print(f"Correct datatype {o.datatype} for property {p} and value {o} with range {range_by_property[str(p)]}") + # correct_datatype += 1 + # else: + # incorrect_datatype += 1 + + if incorrect_datatype + correct_datatype > 0: + normalized_score = 1.0 - (incorrect_datatype / (incorrect_datatype + correct_datatype)) + else: + normalized_score = 0.0 + + return MetricResult( + metric=self, + measurements=[ + Measurement(name="incorrect_datatype", value=incorrect_datatype, unit="number"), + Measurement(name="correct_datatype", value=correct_datatype, unit="number"), + Measurement(name="normalized_score", value=normalized_score, unit="ratio"), + ], + summary=f"Number of incorrect datatype: {incorrect_datatype}", + # name=self.name, + # value=incorrect_datatype, + # normalized_score=normalized_score, + # details={"incorrect_datatype": incorrect_datatype, "correct_datatype": correct_datatype}, + # aspect=self.aspect + ) + + +# @Registry.metric() +# class OntologyClassCoverageMetric(Metric): +# """Check if the KG has correct class coverage.""" +# def __init__(self): +# super().__init__( +# name="ontology_class_coverage", +# description="Check if the KG has correct class coverage", +# aspect=EvaluationAspect.SEMANTIC +# ) + +# def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: +# """Compute ontology class coverage score.""" + +# raw_graph: Graph = kg.get_graph() +# ontology_graph: Graph = kg.get_ontology_graph() +# ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) +# graph = enrich_type_information(raw_graph, ontology) + +# expected_classes = set([c.uri for c in ontology.classes if not c.uri.startswith(str(OWL))]) + +# found_classes = set(str(o) for s, p, o in graph.triples((None, RDF.type, None)) if not str(o).startswith(str(OWL))) + +# true_positive = len(expected_classes & found_classes) +# false_positive = len(found_classes - expected_classes) +# false_negative = len(expected_classes - found_classes) + +# precision = true_positive / (true_positive + false_positive) if true_positive + false_positive > 0 else 0.0 +# recall = true_positive / (true_positive + false_negative) if true_positive + false_negative > 0 else 0.0 +# f1_score = 2 * precision * recall / (precision + recall) if precision + recall > 0 else 0.0 + +# return MetricResult( +# name=self.name, +# value=true_positive, +# normalized_score=f1_score, +# details={"true_positive": true_positive, "false_positive": false_positive, "false_negative": false_negative}, +# aspect=self.aspect +# ) + +# @Registry.metric() +# class OntologyRelationCoverageMetric(Metric): +# """Check if the KG has correct relation coverage.""" +# def __init__(self): +# super().__init__( +# name="ontology_relation_coverage", +# description="Check if the KG has correct relation coverage", +# aspect=EvaluationAspect.SEMANTIC +# ) + +# def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: +# """Compute ontology relation coverage score.""" + +# raw_graph: Graph = kg.get_graph() +# ontology_graph: Graph = kg.get_ontology_graph() +# ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) +# graph = enrich_type_information(raw_graph, ontology) + +# NOT_FILTER: List[str] = [str(OWL), str(RDF), str(RDFS)] + +# expected_relations = set([r.uri for r in ontology.properties]) +# expected_relations = set([r for r in expected_relations if not any(filter(lambda x: r.startswith(x), NOT_FILTER))]) + +# # print(expected_relations) + +# found_relations = set(str(p) for _, p, _ in graph.triples((None, None, None))) +# def filter_relation(r): +# return any(filter(lambda x: r.startswith(x), NOT_FILTER)) +# found_relations = set([r for r in found_relations if not filter_relation(r)]) + +# # print(found_relations) + +# true_positive = len(expected_relations & found_relations) +# false_positive = len(found_relations - expected_relations) +# false_negative = len(expected_relations - found_relations) + +# precision = true_positive / (true_positive + false_positive) if true_positive + false_positive > 0 else 0.0 +# recall = true_positive / (true_positive + false_negative) if true_positive + false_negative > 0 else 0.0 +# f1_score = 2 * precision * recall / (precision + recall) if precision + recall > 0 else 0.0 + +# return MetricResult( +# name=self.name, +# value=true_positive, +# normalized_score=f1_score, +# details={"true_positive": true_positive, "false_positive": false_positive, "false_negative": false_negative, "missing": (expected_relations - found_relations)}, +# aspect=self.aspect +# ) + +# @Registry.metric() +# class OntologyPropertyCoverageMetric(Metric): +# """Check if the KG has correct property coverage.""" +# def __init__(self): +# super().__init__( +# name="ontology_property_coverage", +# description="Check if the KG has correct property coverage", +# aspect=EvaluationAspect.SEMANTIC +# ) + +# def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: +# """Compute ontology property coverage score.""" +# return MetricResult( +# name=self.name, +# value=0.0, +# normalized_score=1.0, +# details={"error": "Not implemented"}, +# aspect=self.aspect +# ) + +# @Registry.metric() +# class OntologyNamespaceCoverageMetric(Metric): +# """Check if the KG has correct namespace coverage.""" +# def __init__(self): +# super().__init__( +# name="ontology_namespace_coverage", +# description="Check if the KG has correct namespace coverage", +# aspect=EvaluationAspect.SEMANTIC +# ) + +# def compute(self, kg: KG, config: SemanticConfig, **kwargs) -> MetricResult: +# """Compute ontology namespace coverage score.""" + +# # graph = kg.get_graph() +# # ontology_graph = kg.get_ontology_graph() +# # if len(ontology_graph) == 0: +# # ontology_graph = graph + +# # ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) + + +# return MetricResult( +# name=self.name, +# value=0.0, +# normalized_score=1.0, +# details={"error": "Not implemented"}, +# aspect=self.aspect +# ) # class OntologyClassCoverageMetric(): # pass @@ -47,4 +610,55 @@ def compute(self, kg: TripleGraph, config: ConsistencyViolationsConfig): # pass # class OntologyNamespaceCoverageMetric(): -# pass \ No newline at end of file +# pass + +# Cardinality Metric + # """Compute incorrect relation cardinality score.""" + + # raw_graph: Graph = kg.get_graph() + # ontology_graph: Graph = kg.get_ontology_graph() + # ontology = OntologyUtil.load_ontology_from_graph(ontology_graph) + # graph = enrich_type_information(raw_graph, ontology) + # if len(ontology_graph) == 0: + # ontology_graph = graph + + # cardinality_by_property = {} + # property_cardinalities: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int)) + # properties_in_graph = set() + + # for s, p, o in graph.triples((None, None, None)): + # properties_in_graph.add(str(p)) + + # for property in properties_in_graph: + # cardinality_by_property[property] = get_property_cardinality(ontology_graph, property) + + # # print(cardinality_by_property) + # # print(property_cardinalities) + + # for s, p, o in graph.triples((None, None, None)): + # if str(p) in cardinality_by_property: + # if str(s) in property_cardinalities[str(p)]: + # property_cardinalities[str(p)][str(s)] += 1 + # else: + # property_cardinalities[str(p)][str(s)] = 1 + + # incorrect_cardinality = 0 + # correct_cardinality = 0 + + # for property, cardinality in property_cardinalities.items(): + # min, max = cardinality_by_property[property] + # for subject, count in cardinality.items(): + # if count > max: + # incorrect_cardinality += 1 + # elif count < min: + # incorrect_cardinality += 1 + # else: + # correct_cardinality += 1 + + # return MetricResult( + # name=self.name, + # value=incorrect_cardinality, + # normalized_score=1.0 - (incorrect_cardinality / (incorrect_cardinality + correct_cardinality)) if incorrect_cardinality + correct_cardinality > 0 else 0.0, + # details={"incorrect_cardinality": incorrect_cardinality, "correct_cardinality": correct_cardinality}, + # aspect=self.aspect + # ) \ No newline at end of file diff --git a/src/kgpipe_eval/utils/kg_utils.py b/src/kgpipe_eval/utils/kg_utils.py index 1032af2..8d0317d 100644 --- a/src/kgpipe_eval/utils/kg_utils.py +++ b/src/kgpipe_eval/utils/kg_utils.py @@ -113,6 +113,15 @@ def _graph(self) -> Graph: else: raise ValueError(f"Unsupported KG type: {type(self.kg)}") + def get_graph(self) -> Graph: + return self._graph() + + def get_ontology_graph(self) -> Graph: + if isinstance(self.kg, KG): + return self.kg.get_ontology_graph() + else: + raise ValueError(f"Unsupported KG type: {type(self.kg)}") + def triples(self, triple_pattern: TriplePattern) -> Iterable[Triple]: g = self._graph() # RDFLib yields (s, p, o) as Identifiers From 4c89c762425975b23950d080ec889e850eac9223 Mon Sep 17 00:00:00 2001 From: Marvin Hofer Date: Sat, 6 Jun 2026 16:46:20 +0200 Subject: [PATCH 39/42] Revise README.md with updated KGI-Bench links Updated links to KGI-Bench documentation and added a new link for KGI-Bench-Movie. --- experiments/moviekg/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/experiments/moviekg/README.md b/experiments/moviekg/README.md index 6fbf591..4363cab 100644 --- a/experiments/moviekg/README.md +++ b/experiments/moviekg/README.md @@ -4,8 +4,9 @@ This directory contains **MovieKG pipeline definitions and execution helpers** f pipelines with KGpipe. Evaluation of the produced KGs is now handled in the **KGI-Bench** repository (Movie benchmark). See: -- `KGI-Bench/docs/reproduce.md` -- `KGI-Bench/docs/cli.md` (includes `kgibench evaluate --benchmark movie ...`) +- [KGI-Bench](https://github.com/ScaDS/KGI-Bench) +- [KGI-Bench-Movie](https://github.com/ScaDS/KGI-Bench/tree/main/benchmarks/kgi-bench-movie) +- [KGI-Bench/docs/cli.md](https://scads.github.io/KGI-Bench/#cli) (includes `kgibench evaluate --benchmark movie ...`) ## What’s in here @@ -119,4 +120,4 @@ Pipeline outputs are written under `$OUTPUT_DIR/$DATASET_SELECT// │   │   └── tmp/ │ ├── json_alt[... trunc] └── medium[... trunc] -``` \ No newline at end of file +``` From d76e9822e91f3a01f4a09fc421cfbeed61c724fb Mon Sep 17 00:00:00 2001 From: Marvin Date: Sat, 6 Jun 2026 16:55:53 +0200 Subject: [PATCH 40/42] docu: gh-page workflow --- .github/workflows/docs.yml | 57 ++++++++++++++++++++++++++++++ docs/{README.md => create-docs.md} | 0 2 files changed, 57 insertions(+) create mode 100644 .github/workflows/docs.yml rename docs/{README.md => create-docs.md} (100%) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..dfae2a4 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,57 @@ +name: docs + +on: + push: + branches: [ "main" ] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install docs dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[docs]" + pip install -e "KGI-Bench/.[docs]" + + - name: Build site + run: | + mkdocs build --strict + (cd KGI-Bench && mkdocs build --strict) + + # Publish KGI-Bench site under /kgibench/ + mkdir -p site/kgibench + cp -r KGI-Bench/site/* site/kgibench/ + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: site + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + diff --git a/docs/README.md b/docs/create-docs.md similarity index 100% rename from docs/README.md rename to docs/create-docs.md From ff6360d1a36c68cb2c42720c0578bbeeb7aeb74b Mon Sep 17 00:00:00 2001 From: Marvin Date: Mon, 8 Jun 2026 12:19:37 +0200 Subject: [PATCH 41/42] docs + moviekg: mkdocs fix; updated zenodo download links; Makefile fix --- .github/workflows/docs.yml | 10 +--------- experiments/moviekg/Makefile | 18 ++++++++++-------- experiments/moviekg/env | 2 +- mkdocs.yml | 2 +- 4 files changed, 13 insertions(+), 19 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dfae2a4..92a01d0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -28,16 +28,9 @@ jobs: run: | python -m pip install --upgrade pip pip install -e ".[docs]" - pip install -e "KGI-Bench/.[docs]" - name: Build site - run: | - mkdocs build --strict - (cd KGI-Bench && mkdocs build --strict) - - # Publish KGI-Bench site under /kgibench/ - mkdir -p site/kgibench - cp -r KGI-Bench/site/* site/kgibench/ + run: mkdocs build --strict - name: Upload artifact uses: actions/upload-pages-artifact@v3 @@ -54,4 +47,3 @@ jobs: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 - diff --git a/experiments/moviekg/Makefile b/experiments/moviekg/Makefile index c220a8c..e3fcdf7 100644 --- a/experiments/moviekg/Makefile +++ b/experiments/moviekg/Makefile @@ -1,6 +1,6 @@ .PHONY: -DATASET_URL := https://zenodo.org/record/17246358/files/inc_movie_kg_datasets.tar.gz?download=1 +ZENODO_RECORD := 17246357 BASE_DIR := ./data # === Main === @@ -68,7 +68,9 @@ clean: $(BASE_DIR)/datasets.tar.gz: @mkdir -p $(BASE_DIR) - @cd $(BASE_DIR) && wget $(DATASET_URL) -O datasets.tar.gz + @cd $(BASE_DIR) && wget "$$(curl -sL https://zenodo.org/api/records/$(ZENODO_RECORD) \ + | jq -r '.files[] | select(.key=="inc_movie_kg_datasets.tar.gz") | .links.self')" \ + -O datasets.tar.gz $(BASE_DIR)/datasets/.extracted: $(BASE_DIR)/datasets.tar.gz @mkdir -p $(BASE_DIR)/datasets @@ -83,10 +85,10 @@ datasets-eval: # === RDF === test-rdf-base: - pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k rdf_base + pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k rdf_a test-rdf-alt: - pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k rdf_alt + pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k rdf_b test-rdf-llm: pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k rdf_llm @@ -94,10 +96,10 @@ test-rdf-llm: # === JSON === test-json-base: - pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k json_base + pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k json_a test-json-alt: - pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k json_alt + pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k json_b test-json-llm: pytest -v -s src/moviekg/pipelines/test_inc_ssp.py -k json_llm @@ -105,10 +107,10 @@ test-json-llm: # === TEXT === test-text-base: - pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k text_base + pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k text_a test-text-alt: - pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k text_alt + pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k text_b test-text-llm: pytest -s -v src/moviekg/pipelines/test_inc_ssp.py -k text_llm diff --git a/experiments/moviekg/env b/experiments/moviekg/env index 92445d4..cb9e376 100644 --- a/experiments/moviekg/env +++ b/experiments/moviekg/env @@ -1,7 +1,7 @@ PIPELINE_CONFIG=pipeline.conf DATASET_SELECT=small -ONTOLOGY_PATH=./data/datasets/movie-ontology.ttl +ONTOLOGY_PATH=./data/datasets/film_10k/ontology.ttl OUTPUT_DIR=./data/results/ DATASET_SMALL=./data/datasets/film_100 diff --git a/mkdocs.yml b/mkdocs.yml index f1400c0..1fafb7d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,7 +32,7 @@ site_dir: site nav: - Home: index.md - Quickstart: quickstart.md - - KGI-Bench (benchmark site): /kgibench/ + - KGI-Bench (benchmark site): https://scads.github.io/KGI-Bench/ - Concepts: - Tasks: tasks.md - Pipelines: pipelines.md From 2bda809d0786cb52d0950df128dd41f18838c118 Mon Sep 17 00:00:00 2001 From: Marvin Date: Mon, 8 Jun 2026 12:26:30 +0200 Subject: [PATCH 42/42] update mkdocs --- docs/index.md | 2 +- docs/reproduce.md | 4 ++-- mkdocs.yml | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/index.md b/docs/index.md index b506b88..4a8a452 100644 --- a/docs/index.md +++ b/docs/index.md @@ -34,7 +34,7 @@ cd experiments/examples - Define tasks: [Task specification](tasks.md) - Build and run pipelines: [Pipelines](pipelines.md) - Configure runs and task parameters: [Configuration](configuration.md) and [Parameters](parameters.md) -- Evaluate generated KGs: [Evaluation](evaluation.md) and [Metrics](metrics/) +- Evaluate generated KGs: [Evaluation](evaluation.md) and [Metrics](metrics/metrics.md) - Understand the internal “PipeKG”: [Meta KG](metakg.md) ## Other Links diff --git a/docs/reproduce.md b/docs/reproduce.md index 70b1d69..1d9349b 100644 --- a/docs/reproduce.md +++ b/docs/reproduce.md @@ -1,7 +1,7 @@ # Rep Experiments (Deprecated but working) -Guidelines to run the [experiments](../experiments) -- see also [moviekg](../experiments/moviekg/README.md) +Guidelines to run the [experiments](https://github.com/ScaDS/KGpipe/tree/main/experiments) +- see also [moviekg](https://github.com/ScaDS/KGpipe/blob/main/experiments/moviekg/README.md) ## Overview diff --git a/mkdocs.yml b/mkdocs.yml index 1fafb7d..cafa4aa 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -51,3 +51,5 @@ nav: - Other: - Adoption (integrating existing pipelines): adoption.md - View/UI: view.md + - Building docs: create-docs.md + - Migration (renamed): migration.md