// Citation review desk. Run with Node.js 20+: node citation-desk.mjs
// Fictional data and curated semantic labels; no API key or dependencies.

/** Citation quality arithmetic, following ALCE section 3.3.
 * Semantic judgments are supplied by the caller. This module does not infer
 * meaning from a matching word, source ID, or successful HTTP request.
 */
export function scoreAnswer(answer, { sources, entails }) {
  if (!Array.isArray(answer) || !answer.length) throw new Error("Provide at least one statement.");
  const sourceMap = new Map(sources.map(source => [source.id, source]));
  if (sourceMap.size !== sources.length) throw new Error("Source IDs must be unique.");
  if (new Set(answer.map(statement => statement.id)).size !== answer.length) throw new Error("Statement IDs must be unique.");
  const check = (statement, ids) => {
    if (!ids.length) return false;
    const result = entails({ statement, passages: ids.map(id => sourceMap.get(id)) });
    if (typeof result !== "boolean") throw new Error("entails must return a boolean judgment; unresolved judgments cannot be scored.");
    return result;
  };
  const statements = answer.map(statement => {
    if (!statement.text?.trim() || !Array.isArray(statement.citations)) throw new Error("Each statement needs text and a citation list.");
    const ids = statement.citations;
    if (new Set(ids).size !== ids.length) throw new Error(`Duplicate citation in ${statement.id}.`);
    for (const id of ids) if (!sourceMap.has(id)) throw new Error(`Unknown source ${id} in ${statement.id}.`);
    const supported = check(statement, ids);
    const citations = ids.map(id => {
      const alone = check(statement, [id]);
      const remainingIds = ids.filter(other => other !== id);
      const without = check(statement, remainingIds);
      const precise = supported && (alone || !without);
      return { id, alone, without, precise, reason: !supported
        ? "The cited set does not support the whole statement, so this citation receives no precision credit."
        : alone ? "This passage supports the whole statement on its own."
        : !without ? "This passage is necessary for the cited set to support the whole statement."
        : "This passage does not support the whole statement alone, and the other citations already support it." };
    });
    return { ...statement, supported, citations };
  });
  const supported = statements.filter(statement => statement.supported).length;
  const citations = statements.flatMap(statement => statement.citations);
  const precise = citations.filter(citation => citation.precise).length;
  return {
    statements,
    recall: { numerator: supported, denominator: statements.length, value: supported / statements.length },
    // An empty denominator is explicitly N/A, not perfect precision.
    precision: { numerator: precise, denominator: citations.length, value: citations.length ? precise / citations.length : null },
  };
}

// Fictional Sachin policy extracts. These are teaching fixtures, not real policies.
const source = (id, title, text) => Object.freeze({ id, title, text, version: "sachin-demo-v1", span: Object.freeze({ start: 0, end: text.length }) });
export const SOURCES = Object.freeze([
  source("S1", "Return window", "Standard items may be returned within 30 days of delivery."),
  source("S2", "Proof of purchase", "A receipt is required for standard-item returns."),
  source("S3", "Refund destination", "Approved refunds go back to the original payment method."),
  source("S4", "Return postage", "Sachin pays the return postage for standard items."),
  source("S5", "Clearance exception", "Clearance items are final sale and cannot be returned."),
  source("S6", "Return-window FAQ", "The return deadline for standard items is 30 days after delivery."),
]);
export const CLAIMS = {
  C1: { id: "C1", text: "Standard items may be returned within 30 days of delivery.", supportSets: [["S1"], ["S6"]], notes: {
    S2: { kind: "unsupported", label: "Does not establish the statement", reason: "S2 requires a receipt. It says nothing about 30 days. A relevant topic is insufficient support." },
  } },
  C2: { id: "C2", text: "A receipt is required for standard-item returns.", supportSets: [["S2"]] },
  C3: { id: "C3", text: "Approved refunds go back to the original payment method.", supportSets: [["S3"]] },
  C4: { id: "C4", text: "Sachin pays the return postage for standard items.", supportSets: [["S4"]] },
  C5: { id: "C5", text: "All items, including clearance items, may be returned within 30 days of delivery.", supportSets: [], notes: {
    S5: { kind: "contradicted", label: "Contradicts the statement", reason: "The statement includes clearance items; S5 explicitly says those items cannot be returned." },
  } },
  C6: { id: "C6", text: "Standard items may be returned within 30 days of delivery, and a receipt is required for their return.", supportSets: [["S1", "S2"], ["S6", "S2"]], notes: {
    S1: { kind: "partial", label: "Supports part of the statement", reason: "S1 establishes the window, but gives no receipt requirement." },
    S2: { kind: "partial", label: "Supports part of the statement", reason: "S2 establishes the receipt requirement, but gives no return window." },
    S6: { kind: "partial", label: "Supports part of the statement", reason: "S6 establishes the window, but gives no receipt requirement." },
  } },
};
for (const entry of Object.values(CLAIMS)) {
  entry.supportSets.forEach(Object.freeze);
  Object.freeze(entry.supportSets);
  if (entry.notes) { Object.values(entry.notes).forEach(Object.freeze); Object.freeze(entry.notes); }
  Object.freeze(entry);
}
Object.freeze(CLAIMS);
export const claim = (id, citations) => ({ id, text: CLAIMS[id].text, citations: [...citations] });
export const initialAnswer = () => [claim("C1", ["S2"]), claim("C2", ["S2"]), claim("C3", []), claim("C4", ["S4"])];

/** Curated support sets, not a live NLI model. Reject edited text rather than
 * silently reusing an old label for a new claim or source passage.
 * For these consistent fixtures, supersets of a supporting set also support it.
 * That assumption must not be carried over to arbitrary, conflicting documents.
 */
export function fixtureEntails({ statement, passages }) {
  const known = CLAIMS[statement.id];
  if (!known || known.text !== statement.text) throw new Error("No curated judgment for this statement text.");
  for (const passage of passages) {
    const original = SOURCES.find(source => source.id === passage.id);
    if (!original || original.text !== passage.text || original.version !== passage.version) throw new Error("No curated judgment for this passage version.");
  }
  const ids = passages.map(passage => passage.id);
  return known.supportSets.some(set => set.every(id => ids.includes(id)));
}

export function describePassage(statement, passage) {
  if (fixtureEntails({ statement, passages: [passage] })) return { kind: "supported", label: "Supports the statement", reason: "The passage establishes the full statement, including its conditions." };
  return CLAIMS[statement.id].notes?.[passage.id] ?? { kind: "unsupported", label: "Does not establish the statement", reason: "This passage does not establish the full statement. Silence is different from contradiction." };
}


const options = { sources: SOURCES, entails: fixtureEntails };
const show = (label, answer) => {
  const { recall, precision } = scoreAnswer(answer, options);
  console.log(`${label}: recall ${recall.numerator}/${recall.denominator}; precision ${precision.numerator}/${precision.denominator}`);
};
const answer = initialAnswer();
show("Original", answer);
answer[0] = claim("C1", ["S1"]);
answer[2] = claim("C3", ["S3"]);
show("Repaired", answer);
answer[0] = claim("C1", ["S1", "S2", "S3"]);
show("Padded", answer);
show("Joint support", [claim("C6", ["S1", "S2"])]);
show("Two full supports", [claim("C1", ["S1", "S6"])]);
show("Overbroad claim", [claim("C5", ["S1", "S5"])]);

// Extension point: scoreAnswer accepts your own entails({ statement, passages }).
// Run a semantic judge for each required passage set first, then have this
// function return its stored boolean judgment. Include text/version/model in
// the cache key. Throw for missing/error/uncertain results instead of guessing.
// This demo makes no model calls and cannot evaluate arbitrary edited prose.
