diff --git a/src/components/Analyses.tsx b/src/components/Analyses.tsx index a5de02c..cf648aa 100644 --- a/src/components/Analyses.tsx +++ b/src/components/Analyses.tsx @@ -1,25 +1,58 @@ -import { number, option as O, readonlyArray as RA, readonlyRecord as RR, readonlyTuple, taskEither as TE } from 'fp-ts'; -import { flow, pipe } from 'fp-ts/lib/function'; -import { Fragment, useCallback, useMemo, useState } from 'react'; -import styled from 'styled-components'; +import { + number, + option as O, + readonlyArray as RA, + readonlyRecord as RR, + readonlyTuple, + taskEither as TE, +} from "fp-ts"; +import { flow, pipe } from "fp-ts/lib/function"; +import { Fragment, useCallback, useMemo, useState } from "react"; +import styled from "styled-components"; -import { Button, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, DialogTrigger } from '@fluentui/react-components'; -import { ReadonlyNonEmptyArray } from 'fp-ts/lib/ReadonlyNonEmptyArray'; -import { useAppDispatch, useAppSelector } from '../app/hooks'; -import { get } from '../lib/object'; -import { eqBid } from '../model/bridge'; -import { AnalysisId, ConstrainedBidPathHash, Generation, GenerationId, getBidPathHash, newAnalysisId, newGenerationId } from '../model/job'; -import { SerializedBidPath, serializedBidPathL } from '../model/serialization'; -import { Path, Paths } from '../model/system'; -import { ConstrainedBid } from '../model/system/core'; -import { scheduleJob } from '../reducers/generator'; -import { addAnalysis, deleteAnalysis, selectAllAnalyses, selectAnalysis, selectAnalysisById, selectGenerationByAnalysis, selectSelectedAnalysis, setAnalysisName } from '../reducers/profile'; -import { selectValidConstrainedBidPaths } from '../reducers/system'; -import { getDealsWithSolutionsByPath } from '../services/idb'; -import SolutionStats from './stats/SolutionStats'; -import StatsPath from './stats/StatsPath'; -import StatsDetails from './stats/StatsDetails'; -import BidPath from './core/BidPath'; +import { + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + DialogTrigger, +} from "@fluentui/react-components"; +import { ReadonlyNonEmptyArray } from "fp-ts/lib/ReadonlyNonEmptyArray"; +import { useAppDispatch, useAppSelector } from "../app/hooks"; +import { get } from "../lib/object"; +import { eqBid } from "../model/bridge"; +import { + AnalysisId, + ConstrainedBidPathHash, + Generation, + GenerationId, + getBidPathHash, + newAnalysisId, + newGenerationId, +} from "../model/job"; +import { SerializedBidPath, serializedBidPathL } from "../model/serialization"; +import { Path, Paths } from "../model/system"; +import { ConstrainedBid } from "../model/system/core"; +import { scheduleJob } from "../reducers/generator"; +import { + addAnalysis, + deleteAnalysis, + selectAllAnalyses, + selectAnalysis, + selectAnalysisById, + selectGenerationByAnalysis, + selectSelectedAnalysis, + setAnalysisName, +} from "../reducers/profile"; +import { selectValidConstrainedBidPaths } from "../reducers/system"; +import { getDealsWithSolutionsByPath } from "../services/idb"; +import SolutionStats from "./stats/SolutionStats"; +import StatsPath from "./stats/StatsPath"; +import StatsDetails from "./stats/StatsDetails"; +import BidPath from "./core/BidPath"; const FlexList = styled.ul` display: flex; @@ -27,249 +60,426 @@ const FlexList = styled.ul` list-style-type: none; padding: 0px; margin: 0px; -` +`; const FlexListItem = styled.li` padding: 0px; margin: 5px; -` +`; interface AnalysisProps { - analysisId: AnalysisId + analysisId: AnalysisId; } const AnalysisView = ({ analysisId }: AnalysisProps) => { - const analysis = useAppSelector(state => pipe( - selectAnalysisById({ state: state.profile, analysisId }), - O.toNullable)) - const dispatch = useAppDispatch() - const dealCount = !analysis ? 0 : pipe(analysis.generations, RA.foldMap(number.MonoidSum)(g => g.dealCount)) - const onRemoveClick = useCallback(() => dispatch(deleteAnalysis(analysisId)), [analysisId, dispatch]) - const onSelectClick = useCallback(() => dispatch(selectAnalysis(analysisId)), [analysisId, dispatch]) - const onNameChange = useCallback(name => dispatch(setAnalysisName(analysisId, name)), [analysisId, dispatch]) - return (<>{analysis && - - onNameChange(e.target.value)} /> -

- Paths: {analysis.paths.length}
- Deals: {dealCount} ({analysis.generations.length} generations)
-

- - -
- }) -} + const analysis = useAppSelector((state) => + pipe(selectAnalysisById({ state: state.profile, analysisId }), O.toNullable) + ); + const dispatch = useAppDispatch(); + const dealCount = !analysis + ? 0 + : pipe( + analysis.generations, + RA.foldMap(number.MonoidSum)((g) => g.dealCount) + ); + const onRemoveClick = useCallback( + () => dispatch(deleteAnalysis(analysisId)), + [analysisId, dispatch] + ); + const onSelectClick = useCallback( + () => dispatch(selectAnalysis(analysisId)), + [analysisId, dispatch] + ); + const onNameChange = useCallback( + (name) => dispatch(setAnalysisName(analysisId, name)), + [analysisId, dispatch] + ); + return ( + <> + {analysis && ( + + onNameChange(e.target.value)} + /> +

+ Paths: {analysis.paths.length}
+ Deals: {dealCount}{" "} + ({analysis.generations.length} generations) +
+

+ + +
+ )} + + ); +}; interface StatsPathItemProps { - generationId: GenerationId - analysisId: AnalysisId - path: SerializedBidPath - pathHash: ConstrainedBidPathHash - count: number -} -const StatsPathItem = ({ path, count, generationId, analysisId, pathHash }: StatsPathItemProps) => { - const generation = useAppSelector(state => pipe( - selectGenerationByAnalysis({ state: state.profile, analysisId, generationId }), - O.toNullable)) - const dispatch = useAppDispatch() - const solve = useCallback(() => pipe( - getDealsWithSolutionsByPath(generationId, pathHash), - TE.map(flow( - RR.filter(d => O.isNone(d.solution)), - RR.map(d => d.deal), - RR.toReadonlyArray, - RA.map(readonlyTuple.snd), - deals => dispatch(scheduleJob({ - analysisId: analysisId, - type: "Solve", - parameter: deals, - context: { generationId: generationId, bidPath: path }, - estimatedUnitsInitial: deals.length - })))))() - , [analysisId, dispatch, generationId, path, pathHash]) - return (<>{generation && <> - {/* Contained in CSS grid, so make sure the node count is consistent with StatsPathContainer CSS */} - - - }) + generationId: GenerationId; + analysisId: AnalysisId; + path: SerializedBidPath; + pathHash: ConstrainedBidPathHash; + count: number; } +const StatsPathItem = ({ + path, + count, + generationId, + analysisId, + pathHash, +}: StatsPathItemProps) => { + const generation = useAppSelector((state) => + pipe( + selectGenerationByAnalysis({ + state: state.profile, + analysisId, + generationId, + }), + O.toNullable + ) + ); + const dispatch = useAppDispatch(); + const solve = useCallback( + () => + pipe( + getDealsWithSolutionsByPath(generationId, pathHash), + TE.map( + flow( + RR.filter((d) => O.isNone(d.solution)), + RR.map((d) => d.deal), + RR.toReadonlyArray, + RA.map(readonlyTuple.snd), + (deals) => + dispatch( + scheduleJob({ + analysisId: analysisId, + type: "Solve", + parameter: deals, + context: { generationId: generationId, bidPath: path }, + estimatedUnitsInitial: deals.length, + }) + ) + ) + ) + )(), + [analysisId, dispatch, generationId, path, pathHash] + ); + return ( + <> + {generation && ( + <> + {/* Contained in CSS grid, so make sure the node count is consistent with StatsPathContainer CSS */} + + + + )} + + ); +}; interface StatsModelProps { - path: SerializedBidPath - generation: Generation, - solve: () => void + path: SerializedBidPath; + generation: Generation; + solve: () => void; } const StatsModal = ({ path, generation, solve }: StatsModelProps) => { const [showDetails, setShowDetails] = useState(false); - const onShowClick = useCallback((open: boolean) => { - if (open) { - solve(); - } - setShowDetails(open); - }, [solve]); - return <> - onShowClick(data.open)}> - - - - - - Solution Details - - setShowDetails(false)} /> - - - - - - - - - -} + const onShowClick = useCallback( + (open: boolean) => { + if (open) { + solve(); + } + setShowDetails(open); + }, + [solve] + ); + return ( + <> + onShowClick(data.open)} + > + + + + + + + Solution Details -{" "} + + + setShowDetails(false)} + /> + + + + + + + + + + ); +}; const StatsPathContainer = styled.div` - clear: both; - display: inline-grid; - grid-column-gap: 5px; - grid-template-columns: auto auto auto; - width: auto; - ` + clear: both; + display: inline-grid; + grid-column-gap: 5px; + grid-template-columns: auto auto auto; + width: auto; +`; interface GenerationViewProps { - analysisId: AnalysisId - generationId: GenerationId + analysisId: AnalysisId; + generationId: GenerationId; } const GenerationView = ({ analysisId, generationId }: GenerationViewProps) => { - const generation = useAppSelector(state => pipe( - selectGenerationByAnalysis({ state: state.profile, analysisId, generationId }), - O.toNullable)) - const satisfies = useMemo(() => pipe( - generation, - O.fromNullable, - O.chain(get("satisfies")), - O.map(RR.toReadonlyArray), - O.toNullable) - , [generation]) - const paths = useAppSelector(state => pipe( - selectAnalysisById({ state: state.profile, analysisId }), - O.map(get("paths")), - O.toNullable)) + const generation = useAppSelector((state) => + pipe( + selectGenerationByAnalysis({ + state: state.profile, + analysisId, + generationId, + }), + O.toNullable + ) + ); + const satisfies = useMemo( + () => + pipe( + generation, + O.fromNullable, + O.chain(get("satisfies")), + O.map(RR.toReadonlyArray), + O.toNullable + ), + [generation] + ); + const paths = useAppSelector((state) => + pipe( + selectAnalysisById({ state: state.profile, analysisId }), + O.map(get("paths")), + O.toNullable + ) + ); - const dispatch = useAppDispatch() - const onSatisfiesClick = useCallback(() => generation && paths && dispatch(scheduleJob({ - analysisId: analysisId, - type: "Satisfies", - parameter: paths, - context: { generationId: generation.id }, - estimatedUnitsInitial: paths.length * generation.dealCount - })), [analysisId, dispatch, generation, paths]) + const dispatch = useAppDispatch(); + const onSatisfiesClick = useCallback( + () => + generation && + paths && + dispatch( + scheduleJob({ + analysisId: analysisId, + type: "Satisfies", + parameter: paths, + context: { generationId: generation.id }, + estimatedUnitsInitial: paths.length * generation.dealCount, + }) + ), + [analysisId, dispatch, generation, paths] + ); - const getHash = useCallback((path: SerializedBidPath) => pipe( - paths, - O.fromNullable, - O.chain(RA.findFirst(flow( - RA.map(get('bid')), - bids => RA.getEq(eqBid).equals(bids, serializedBidPathL.reverseGet(path))))), - O.map(getBidPathHash), - O.toNullable - ), [paths]) + const getHash = useCallback( + (path: SerializedBidPath) => + pipe( + paths, + O.fromNullable, + O.chain( + RA.findFirst( + flow(RA.map(get("bid")), (bids) => + RA.getEq(eqBid).equals(bids, serializedBidPathL.reverseGet(path)) + ) + ) + ), + O.map(getBidPathHash), + O.toNullable + ), + [paths] + ); - return (<>{generation && - - Deal Count: {generation.dealCount}
- {satisfies && - {satisfies.map(([path, count]) => { - const pathHash = getHash(path) - return ( - {pathHash && } - ) - })} - } -
- }) -} + return ( + <> + {generation && ( + + Deal Count: {generation.dealCount}
+ {satisfies && ( + + {satisfies.map(([path, count]) => { + const pathHash = getHash(path); + return ( + + {pathHash && ( + + )} + + ); + })} + + )} +
+ )} + + ); +}; const SelectedAnalysis = () => { - const analysis = useAppSelector(state => pipe(selectSelectedAnalysis(state.profile), O.toNullable)) - const generateCount = useAppSelector(state => state.settings.generateCount) + const analysis = useAppSelector((state) => + pipe(selectSelectedAnalysis(state.profile), O.toNullable) + ); + const generateCount = useAppSelector((state) => state.settings.generateCount); - const dispatch = useAppDispatch() - const onGenerateDealsClick = useCallback((count: number) => { - if (analysis) { - dispatch(scheduleJob({ - analysisId: analysis.id, - type: "GenerateDeals", - context: { generationId: newGenerationId() }, - parameter: count, - estimatedUnitsInitial: count - })) - } - }, [analysis, dispatch]) + const dispatch = useAppDispatch(); + const onGenerateDealsClick = useCallback( + (count: number) => { + if (analysis) { + dispatch( + scheduleJob({ + analysisId: analysis.id, + type: "GenerateDeals", + context: { generationId: newGenerationId() }, + parameter: count, + estimatedUnitsInitial: count, + }) + ); + } + }, + [analysis, dispatch] + ); - return (<>{analysis && -
-

{analysis.name}

- - {analysis.generations.map(g => )} - - -
- }) -} + return ( + <> + {analysis && ( +
+

{analysis.name}

+ + {analysis.generations.map((g) => ( + + ))} + + +
+ )} + + ); +}; interface NewAnalysisProps { - paths: ReadonlyNonEmptyArray> - onSubmitOrClose: () => void + paths: ReadonlyNonEmptyArray>; + onSubmitOrClose: () => void; } const NewAnalysis = ({ paths, onSubmitOrClose }: NewAnalysisProps) => { - const dispatch = useAppDispatch() - const onGoClick = useCallback((name: string, count: number, paths: Paths) => { - onSubmitOrClose() - dispatch(addAnalysis({ id: newAnalysisId(), name, count, paths })) - }, [dispatch, onSubmitOrClose]) - const defaultCount = useAppSelector(state => state.settings["generateCount"]) - const [name, setName] = useState(`New analysis (${paths.length} paths)`) + const dispatch = useAppDispatch(); + const onGoClick = useCallback( + (name: string, count: number, paths: Paths) => { + onSubmitOrClose(); + dispatch(addAnalysis({ id: newAnalysisId(), name, count, paths })); + }, + [dispatch, onSubmitOrClose] + ); + const defaultCount = useAppSelector( + (state) => state.settings["generateCount"] + ); + const [name, setName] = useState( + `New analysis (${paths.length} paths)` + ); const [count, setCount] = useState(defaultCount); return (

- Name setName(e.target.value)} /> + Name{" "} + setName(e.target.value)} + />
- Hands to Generate pipe(e.target.value, parseInt, setCount)} style={{ width: "100px" }} /> + Hands to Generate{" "} + pipe(e.target.value, parseInt, setCount)} + style={{ width: "100px" }} + />

- +
- ) -} + ); +}; const Analyses = () => { const [newAnalysis, setNewAnalysis] = useState(false); - const paths = useAppSelector(state => pipe( - selectValidConstrainedBidPaths({ state: state.system, options: state.settings }), - O.toNullable)) + const paths = useAppSelector((state) => + pipe( + selectValidConstrainedBidPaths({ + state: state.system, + options: state.settings, + }), + O.toNullable + ) + ); return (

Analyses

- {paths && (<> - setNewAnalysis(data.open)}> - - - - - - New Analysis - setNewAnalysis(false)} /> - - - - )} + {paths && ( + <> + setNewAnalysis(data.open)} + > + + + + + + New Analysis + setNewAnalysis(false)} + /> + + + + + )}
- ) -} - -export default Analyses + ); +}; +export default Analyses; diff --git a/src/components/core/BidSelector.tsx b/src/components/core/BidSelector.tsx new file mode 100644 index 0000000..ce2cd56 --- /dev/null +++ b/src/components/core/BidSelector.tsx @@ -0,0 +1,37 @@ +import { readonlyRecord as RR, readonlyArray as RA, } from "fp-ts" +import { ContractBid, eqContractBid, levels, strains } from "../../model/bridge" +import StrainTableView, { StrainSpan } from "./StrainTableView" +import { pipe } from "fp-ts/lib/function" +import { Button } from '@fluentui/react-components'; + +interface BidSelectorButtonProps { + selected: boolean, + children: JSX.Element + setSelected: (selected: boolean) => void +} +const BidSelectorButton = ({ children, selected, setSelected }: BidSelectorButtonProps) => + + +interface BidSelectorProps { + bids: ReadonlyArray, + setSelectedBid: (bid: ContractBid, selected: boolean) => void +} +export const BidSelector = ({ bids, setSelectedBid }: BidSelectorProps) => { + const contractBidsEnabledByLevel = + pipe(levels, + RA.map(level => pipe(strains, + RA.map(strain => ({ level, strain }) as ContractBid), + RA.map(bid => [bid.strain, pipe(bids, RA.exists(selectedBid => eqContractBid.equals(bid, selectedBid)))] as const), + RR.fromEntries))) + return ( undefined} + renderRowHeader={() => undefined} + renderCell={(selected, s, i) => + setSelectedBid({ level: i + 1, strain: s}, selected)}> + <>{i + 1} + } + />) +} + +export default BidSelector \ No newline at end of file diff --git a/src/components/core/StrainTableView.tsx b/src/components/core/StrainTableView.tsx new file mode 100644 index 0000000..f33f261 --- /dev/null +++ b/src/components/core/StrainTableView.tsx @@ -0,0 +1,48 @@ +import styled from 'styled-components'; +import { readonlyRecord as RR } from 'fp-ts'; +import { Strain, strains } from '../../model/bridge'; + +const suitBase = ` + &.S::before { content: "♠"; color: #0000FF } + &.H::before { content: "♥"; color: #FF0000 } + &.D::before { content: "♦"; color: #FFA500 } + &.C::before { content: "♣"; color: #32CD32 } +` + +export const StrainSpan = styled.span ` + ${suitBase} + &.N::before { content: "NT"; color: #000000; font-size: 12px; } +` + +type StrainRow = RR.ReadonlyRecord +export interface StrainTable { + rows: ReadonlyArray> +} + +interface StrainTableProps { + table: StrainTable + renderColHeader: ((strain: Strain, index: number) => JSX.Element | undefined) + renderRowHeader: (row: StrainRow, index: number) => JSX.Element | undefined + renderCell: (value: T, strain: Strain, rowIndex: number) => JSX.Element | undefined +} + +export const StrainTableView = ({ table, renderColHeader, renderRowHeader, renderCell }: StrainTableProps) => { + return ( + + + + + {strains.map((s, i) => renderColHeader ? renderColHeader(s, i) : )} + + + + {table.rows.map((r, i) => + + {strains.map((s, j) => )} + )} + +
{renderRowHeader(r, i)}{renderCell(r[s], s, i)}
+ ) +} + +export default StrainTableView \ No newline at end of file diff --git a/src/components/stats/ScoreComparison.tsx b/src/components/stats/ScoreComparison.tsx new file mode 100644 index 0000000..b7a5853 --- /dev/null +++ b/src/components/stats/ScoreComparison.tsx @@ -0,0 +1,57 @@ +import { ContractBid, Direction, eqContractBid, ordContractBid } from "../../model/bridge"; +import { readonlyArray as RA, readonlyNonEmptyArray as RNEA } from "fp-ts"; +import { Scores, compareScores } from "../../model/stats" +import Percentage from "../core/Percentage"; +import BidSelector from "../core/BidSelector"; +import { useCallback, useMemo, useState } from "react"; +import { pipe } from "fp-ts/lib/function"; +import styled from "styled-components"; +import { BidView } from "../core/BidPath"; +import { serializedBidL } from "../../model/serialization"; + +interface ScoreComparisonProps { + contractBid: ContractBid + scores: Scores +} +const defaultDir : Direction = "N" + +export const Columns = styled.div ` + display: flex; + flex-direction: row; +` + +export const Column = styled.div ` + width: 35%; +` + +export const ScoreList = styled.ul ` + list-style-type: none; +` + +const ScoreComparison = ({ contractBid, scores }: ScoreComparisonProps) => { + const [bids, setBids] = useState>([contractBid]) + const setSelectedBid = useCallback((bid: ContractBid, selected: boolean) => + setBids(pipe( + bids, + selected ? RA.union(eqContractBid)([bid]) : RA.filter(b => !eqContractBid.equals(bid, b)), + RA.append(contractBid), + RNEA.uniq(eqContractBid), + RNEA.sort(ordContractBid))) + , [bids, contractBid]) + const comparison = useMemo(() => compareScores(scores)(pipe(bids, RNEA.map(b => ([defaultDir, b])))), [bids, scores]) + const length = scores.length + return (
+

Compare Contracts

+ + + {length && Object.entries(comparison).map(([contract, count], i) => +
  • + {contract === "tie" ? "(ties)" : } + : {count} () +
  • )} +
    +
    +
    ) +} + +export default ScoreComparison \ No newline at end of file diff --git a/src/components/stats/StatsDetails.tsx b/src/components/stats/StatsDetails.tsx index 6c4ba8d..502de99 100644 --- a/src/components/stats/StatsDetails.tsx +++ b/src/components/stats/StatsDetails.tsx @@ -1,33 +1,66 @@ -import { option as O, readonlyRecord as RR } from 'fp-ts'; -import { flow, pipe } from 'fp-ts/lib/function'; -import { get } from '../../lib/object'; +import { + option as O, + readonlyRecord as RR, + readonlyNonEmptyArray as RNEA, +} from "fp-ts"; +import { flow, pipe } from "fp-ts/lib/function"; +import { get } from "../../lib/object"; import { Generation } from "../../model/job"; -import { SerializedBidPath } from "../../model/serialization"; +import { + SerializedBidPath, + serializedBidPathL, +} from "../../model/serialization"; import SolutionStats from "./SolutionStats"; +import ScoreComparison from "./ScoreComparison"; +import { ContractBid } from "../../model/bridge"; +import styled from "styled-components"; + +export const Columns = styled.div` + display: flex; + flex-direction: row; +`; + +export const Column = styled.div` + width: 35%; +`; interface StatsDetailsProps { - path: SerializedBidPath - generation: Generation - onClose: () => void + path: SerializedBidPath; + generation: Generation; + onClose: () => void; } const StatsDetails = ({ path, generation, onClose }: StatsDetailsProps) => { const stats = pipe( generation, O.fromNullable, - O.chain(flow( - get('solutionStats'), - RR.lookup(path))), - O.toNullable) + O.chain(flow(get("solutionStats"), RR.lookup(path))), + O.toNullable + ); const solveCount = pipe( stats, O.fromNullable, - O.map(get('count')), - O.chain(O.fromPredicate(len => len > 0)), - O.toNullable) - return (
    - {solveCount &&

    {solveCount} solutions found

    } - {stats && } -
    ) -} + O.map(get("count")), + O.chain(O.fromPredicate((len) => len > 0)), + O.toNullable + ); + const contractBid = pipe( + path, + serializedBidPathL.reverseGet, + RNEA.last + ) as ContractBid; + return ( +
    + {solveCount &&

    {solveCount} solutions found

    } + + {stats && } +
    + {stats?.scores && ( + + )} +
    +
    +
    + ); +}; -export default StatsDetails \ No newline at end of file +export default StatsDetails; diff --git a/src/model/analyze.ts b/src/model/analyze.ts index a008c3c..9c483b7 100644 --- a/src/model/analyze.ts +++ b/src/model/analyze.ts @@ -1,75 +1,156 @@ -import { either, eitherT, option, readonlyArray, readonlyMap, readonlyNonEmptyArray as RNEA, readonlyRecord as RR, readonlyTuple, refinement, semigroup } from 'fp-ts'; -import { constant, flow, pipe } from 'fp-ts/lib/function'; -import { ordAscending, ordDescending } from '../lib'; -import { ContractBid, contractBids, ContractModifier, Direction, getIsVulnerable, ordContractBid, Strain, Vulnerability } from './bridge'; -import { eqScore, making, ordScore, Score, score, zeroScore } from './score'; - +import { + either, + eitherT, + option, + readonlyArray, + readonlyMap, + readonlyNonEmptyArray as RNEA, + readonlyRecord as RR, + readonlyTuple, + refinement, + semigroup, + eq, +} from "fp-ts"; +import { constant, flow, pipe } from "fp-ts/lib/function"; +import { ordAscending, ordDescending } from "../lib"; +import { + ContractBid, + contractBids, + ContractModifier, + Direction, + eqContractBid, + getIsVulnerable, + ordContractBid, + Strain, + Vulnerability, +} from "./bridge"; +import { eqScore, making, ordScore, Score, score, zeroScore } from "./score"; -export type TrickCountsByStrain = RR.ReadonlyRecord -export type TrickCountsByDirection = RR.ReadonlyRecord -export type TrickCountsByStrainThenDirection = RR.ReadonlyRecord -export type TrickCountsByDirectionThenStrain = RR.ReadonlyRecord +export type TrickCountsByStrain = RR.ReadonlyRecord; +export type TrickCountsByDirection = RR.ReadonlyRecord; +export type TrickCountsByStrainThenDirection = RR.ReadonlyRecord< + Strain, + TrickCountsByDirection +>; +export type TrickCountsByDirectionThenStrain = RR.ReadonlyRecord< + Direction, + TrickCountsByStrain +>; -export const flattenNestedCounts = (table: RR.ReadonlyRecord>) => - pipe(readonlyArray.Do, - readonlyArray.apS('inner', pipe(table, RR.toReadonlyArray)), - readonlyArray.bind('outer', ({ inner }) => pipe(inner[1], RR.toReadonlyArray)), - readonlyArray.map(({ outer, inner }) => ({ outerKey: outer[0], innerKey: inner[0], value: outer[1] }))) +export const flattenNestedCounts = ( + table: RR.ReadonlyRecord> +) => + pipe( + readonlyArray.Do, + readonlyArray.apS("inner", pipe(table, RR.toReadonlyArray)), + readonlyArray.bind("outer", ({ inner }) => + pipe(inner[1], RR.toReadonlyArray) + ), + readonlyArray.map(({ outer, inner }) => ({ + outerKey: outer[0], + innerKey: inner[0], + value: outer[1], + })) + ); -export const transpose = (table: RR.ReadonlyRecord>) : RR.ReadonlyRecord> => - pipe(table, +export const transpose = ( + table: RR.ReadonlyRecord> +): RR.ReadonlyRecord> => + pipe( + table, flattenNestedCounts, RNEA.fromReadonlyArray, - option.fold(() => ({}), - RNEA.groupBy(x => x.outerKey)), - RR.map(flow( - RNEA.fromReadonlyArray, - option.fold(() => ({}), - RNEA.groupBy(x => x.innerKey)), - RR.map(x => x[0].value)))) - + option.fold( + () => ({}), + RNEA.groupBy((x) => x.outerKey) + ), + RR.map( + flow( + RNEA.fromReadonlyArray, + option.fold( + () => ({}), + RNEA.groupBy((x) => x.innerKey) + ), + RR.map((x) => x[0].value) + ) + ) + ); -type OptimalBid = ContractBid | "Pass" -const initialContractBid: refinement.Refinement = (b): b is ContractBid => b !== "Pass" +export type OptimalBid = ContractBid | "Pass"; +export const eqOptimalBid: eq.Eq = { + equals: (a, b) => + (!(a === "Pass" || b === "Pass") && eqContractBid.equals(a, b)) || a === b, +}; +const initialContractBid: refinement.Refinement = ( + b +): b is ContractBid => b !== "Pass"; const initialBids = pipe( contractBids, readonlyArray.sort(ordContractBid), - readonlyArray.prepend("Pass")) -const ordInitialBidsAscending = ordAscending(initialBids) -const ordInitialBidsDescending = ordDescending(initialBids) + readonlyArray.prepend("Pass") +); +const ordInitialBidsDescending = ordDescending(initialBids); -type ContractScorePair = readonly [OptimalBid, Score] -const getDirectionScores = (counts: TrickCountsByStrain) => (isVulnerable: boolean) => - pipe(initialBids, - readonlyArray.map(either.fromPredicate(initialContractBid, () => "Pass" as const)), - eitherT.match(readonlyArray.Functor)( - (pass): ContractScorePair => [pass, zeroScore], - (bid): ContractScorePair => { - const tricks = counts[bid.strain] - const modifier: ContractModifier = pipe( - making({ ...bid, modifier: "Undoubled" }, tricks), - either.fold(constant("Doubled"), constant("Undoubled"))) - return [bid, score({ contract: { ...bid, modifier}, tricks, isVulnerable })] - })) +export type ContractScorePair = readonly [OptimalBid, Score]; +const getDirectionScores = + (counts: TrickCountsByStrain) => (isVulnerable: boolean) => + pipe( + initialBids, + readonlyArray.map( + either.fromPredicate(initialContractBid, () => "Pass" as const) + ), + eitherT.match(readonlyArray.Functor)( + (pass): ContractScorePair => [pass, zeroScore], + (bid): ContractScorePair => { + const tricks = counts[bid.strain]; + const modifier: ContractModifier = pipe( + making({ ...bid, modifier: "Undoubled" }, tricks), + either.fold(constant("Doubled"), constant("Undoubled")) + ); + return [ + bid, + score({ contract: { ...bid, modifier }, tricks, isVulnerable }), + ]; + } + ) + ); -const getAllScores = (table: TrickCountsByDirectionThenStrain) => (vulnerability: Vulnerability) => - pipe(table, - RR.mapWithIndex((direction, counts) => - getDirectionScores(counts)(getIsVulnerable(direction, vulnerability)))) +export const getAllScores = + (vulnerability: Vulnerability) => (table: TrickCountsByDirectionThenStrain) => + pipe( + table, + RR.mapWithIndex((direction, counts) => + getDirectionScores(counts)(getIsVulnerable(direction, vulnerability)) + ) + ); // const byContractDescending : ord.Ord = // ord.contramap(readonlyTuple.fst)(ordInitialBidsDescending) -const shakeContracts = (contractScorePairs: RNEA.ReadonlyNonEmptyArray) => - pipe(contractScorePairs, +const shakeContracts = ( + contractScorePairs: RNEA.ReadonlyNonEmptyArray +) => + pipe( + contractScorePairs, RNEA.map(readonlyTuple.swap), - readonlyMap.fromFoldable(eqScore, semigroup.max(ordInitialBidsDescending), RNEA.Foldable), - readonlyMap.toReadonlyArray(ordScore)) + readonlyMap.fromFoldable( + eqScore, + semigroup.max(ordInitialBidsDescending), + RNEA.Foldable + ), + readonlyMap.toReadonlyArray(ordScore) + ); -const pvs = (direction: Direction, availableMoves: RNEA.ReadonlyNonEmptyArray): OptimalBid => "Pass" +const pvs = ( + direction: Direction, + availableMoves: RNEA.ReadonlyNonEmptyArray +): OptimalBid => "Pass"; export interface IndependentVariables { - dealer: Direction - vulnerability: Vulnerability + dealer: Direction; + vulnerability: Vulnerability; } -export const parScore = (table: TrickCountsByStrainThenDirection) => (vars: IndependentVariables): Score => zeroScore +export const parScore = + (table: TrickCountsByStrainThenDirection) => + (vars: IndependentVariables): Score => + zeroScore; diff --git a/src/model/bridge.ts b/src/model/bridge.ts index bf9fe9f..a68d7b8 100644 --- a/src/model/bridge.ts +++ b/src/model/bridge.ts @@ -1,149 +1,211 @@ -import { apply, eq, number, ord, readonlyArray as RA, readonlyNonEmptyArray as RNEA, readonlyRecord, readonlySet as RS, readonlyTuple as RT, refinement, string } from 'fp-ts'; -import { flow, pipe } from 'fp-ts/lib/function'; - -import { ordAscending } from '../lib'; -import { Deck, eqCard, Hand, suits } from './deck'; - -export const directions = ['N', 'E', 'S', 'W'] as const -export type Direction = typeof directions[number] -export const eqDirection : eq.Eq = string.Eq -export const ordDirection = ordAscending(directions) - -export const partnerships = ["NorthSouth", "EastWest"] as const -export type Partnership = typeof partnerships[number] -export const eqPartnership : eq.Eq = string.Eq -export const ordPartnership = ordAscending(partnerships) +import { + apply, + eq, + number, + ord, + readonlyArray as RA, + readonlyNonEmptyArray as RNEA, + readonlyRecord, + readonlySet as RS, + readonlyTuple as RT, + refinement, + string, +} from "fp-ts"; +import { flow, pipe } from "fp-ts/lib/function"; + +import { ordAscending } from "../lib"; +import { Deck, eqCard, Hand, suits } from "./deck"; + +export const directions = ["N", "E", "S", "W"] as const; +export type Direction = (typeof directions)[number]; +export const eqDirection: eq.Eq = string.Eq; +export const ordDirection = ordAscending(directions); + +export const partnerships = ["NorthSouth", "EastWest"] as const; +export type Partnership = (typeof partnerships)[number]; +export const eqPartnership: eq.Eq = string.Eq; +export const ordPartnership = ordAscending(partnerships); export const getPartnershipByDirection = (d: Direction): Partnership => - (d === 'N' || d === 'S') ? "NorthSouth" : "EastWest" + d === "N" || d === "S" ? "NorthSouth" : "EastWest"; -export type Deal = readonlyRecord.ReadonlyRecord +export type Deal = readonlyRecord.ReadonlyRecord; export type Player = { - direction: Direction - hand: Hand -} -export const eqHand : eq.Eq = RS.getEq(eqCard) + direction: Direction; + hand: Hand; +}; +export const eqHand: eq.Eq = RS.getEq(eqCard); -export const deal = (deck: Deck) : Deal => - pipe(directions, +export const deal = (deck: Deck): Deal => + pipe( + directions, RNEA.zip(RNEA.chunksOf(13)(deck)), RNEA.groupBy(RT.fst), readonlyRecord.map(flow(RNEA.head, RT.snd, RS.fromReadonlyArray(eqCard))), - (x: readonlyRecord.ReadonlyRecord) => x) -export const eqDeal : eq.Eq = - readonlyRecord.getEq(eqHand) - -export const vulnerabilities = ["Neither", "NorthSouth", "EastWest", "Both"] as const -export type Vulnerability = typeof vulnerabilities[number] - -export const getIsVulnerable = (dir: Direction, vul: Vulnerability) => - !(vul === "Neither") && ((vul === "Both") || getPartnershipByDirection(dir) === vul) - -export const strains = [...suits, 'N'] as const -export type Strain = typeof strains[number] -export const eqStrain : eq.Eq = eq.eqStrict -export const ordStrain : ord.Ord = ordAscending(strains) - -export const minors: ReadonlyArray = ['C', 'D'] -export const majors: ReadonlyArray = ['H', 'S'] + (x: readonlyRecord.ReadonlyRecord) => x + ); +export const eqDeal: eq.Eq = readonlyRecord.getEq( + eqHand +); + +export const vulnerabilities = [ + "Neither", + "NorthSouth", + "EastWest", + "Both", +] as const; +export type Vulnerability = (typeof vulnerabilities)[number]; + +export const getIsVulnerable = (dir: Direction, vul: Vulnerability) => + !(vul === "Neither") && + (vul === "Both" || getPartnershipByDirection(dir) === vul); + +export const strains = [...suits, "N"] as const; +export type Strain = (typeof strains)[number]; +export const eqStrain: eq.Eq = eq.eqStrict; +export const ordStrain: ord.Ord = ordAscending(strains); + +export const minors: ReadonlyArray = ["C", "D"]; +export const majors: ReadonlyArray = ["H", "S"]; export interface Board { - dealer: Direction - deal: Deal + dealer: Direction; + deal: Deal; } -export const eqBoard : eq.Eq = eq.struct({ +export const eqBoard: eq.Eq = eq.struct({ dealer: eqDirection, - deal: eqDeal -}) + deal: eqDeal, +}); export interface BoardWithDetail extends Board { - number: number - vulnerability: Vulnerability + number: number; + vulnerability: Vulnerability; } -const boneChart = (boardNumber: number) : Vulnerability => { +const boneChart = (boardNumber: number): Vulnerability => { switch ((boardNumber % 16) + 1) { - case 1: case 8: case 11: case 14: return "Neither" - case 2: case 5: case 12: case 15: return "NorthSouth" - case 3: case 6: case 9: case 16: return "EastWest" - case 4: case 7: case 10: case 13: return "Both" - default: throw Error("Not possible") + case 1: + case 8: + case 11: + case 14: + return "Neither"; + case 2: + case 5: + case 12: + case 15: + return "NorthSouth"; + case 3: + case 6: + case 9: + case 16: + return "EastWest"; + case 4: + case 7: + case 10: + case 13: + return "Both"; + default: + throw Error("Not possible"); } -} - -export const makeBoard = (number: number) => (deal: Deal) : BoardWithDetail => ({ - number, - dealer: directions[(number - 1) % directions.length], - deal, - vulnerability: boneChart(number) -}) - -export const nonContractBids = ["Pass", "Double", "Redouble"] as const -export type NonContractBid = typeof nonContractBids[number] -export const eqNonContractBid : eq.Eq = string.Eq -export const isNonContractBid = (b: unknown) : b is NonContractBid => - typeof b === "string" && pipe(nonContractBids, RA.elem(string.Eq)(b)) +}; + +export const makeBoard = + (number: number) => + (deal: Deal): BoardWithDetail => ({ + number, + dealer: directions[(number - 1) % directions.length], + deal, + vulnerability: boneChart(number), + }); + +export const nonContractBids = ["Pass", "Double", "Redouble"] as const; +export type NonContractBid = (typeof nonContractBids)[number]; +export const eqNonContractBid: eq.Eq = string.Eq; +export const isNonContractBid = (b: unknown): b is NonContractBid => + typeof b === "string" && pipe(nonContractBids, RA.elem(string.Eq)(b)); export interface ContractBid { - level: number - strain: Strain + level: number; + strain: Strain; } -export const eqContractBid : eq.Eq = eq.struct({ +export const eqContractBid: eq.Eq = eq.struct({ level: number.Eq, - strain: string.Eq -}) -export const ordContractBid : ord.Ord = - ord.getMonoid().concat( - pipe(number.Ord, ord.contramap(c => c.level)), - pipe(ordStrain, ord.contramap(c => c.strain)) - ) -export const contractBids : ReadonlyArray = - pipe( - apply.sequenceS(RA.Apply)(({ - level: RA.makeBy(7, level => level + 1), - strain: strains - })), - RA.sort(ordContractBid)) - -export type Bid = NonContractBid | ContractBid -export const isContractBid = (b: Bid): b is ContractBid => !isNonContractBid(b) - -export const eqBid : eq.Eq = eq.fromEquals((x, y) => - (isNonContractBid(x) && isNonContractBid(y) && eqNonContractBid.equals(x, y)) || - (!isNonContractBid(x) && !isNonContractBid(y) && eqContractBid.equals(x, y))) + strain: string.Eq, +}); +export const ordContractBid: ord.Ord = ord + .getMonoid() + .concat( + pipe( + number.Ord, + ord.contramap((c) => c.level) + ), + pipe( + ordStrain, + ord.contramap((c) => c.strain) + ) + ); +export const levels = RNEA.makeBy((level) => level + 1)(7); +export const contractBids: ReadonlyArray = pipe( + apply.sequenceS(RA.Apply)({ + level: levels, + strain: strains, + }), + RA.sort(ordContractBid) +); + +export type Bid = NonContractBid | ContractBid; +export const isContractBid = (b: Bid): b is ContractBid => !isNonContractBid(b); + +export const eqBid: eq.Eq = eq.fromEquals( + (x, y) => + (isNonContractBid(x) && + isNonContractBid(y) && + eqNonContractBid.equals(x, y)) || + (!isNonContractBid(x) && !isNonContractBid(y) && eqContractBid.equals(x, y)) +); export const isGameLevel = (bid: Bid) => - isContractBid(bid) && ord.geq(ordContractBid)(bid, { level: 3, strain: "N" }) + isContractBid(bid) && ord.geq(ordContractBid)(bid, { level: 3, strain: "N" }); export const isSlamLevel = (bid: Bid) => - isContractBid(bid) && ord.gt(ordContractBid)(bid, { level: 5, strain: "N" }) + isContractBid(bid) && ord.gt(ordContractBid)(bid, { level: 5, strain: "N" }); -export const contractModifiers = ["Undoubled", "Doubled", "Redoubled"] as const -export type ContractModifier = typeof contractModifiers[number] +export const contractModifiers = ["Undoubled", "Doubled", "Redoubled"] as const; +export type ContractModifier = (typeof contractModifiers)[number]; export interface Contract extends ContractBid { - modifier: ContractModifier + modifier: ContractModifier; } -export const eqContract : eq.Eq = eq.struct({ +export const eqContract: eq.Eq = eq.struct({ level: number.Eq, strain: string.Eq, - modifier: string.Eq -}) -export const fromBid = (bid: ContractBid): Contract => - ({ ...bid, modifier: "Undoubled" }) - -export type Auction = RNEA.ReadonlyNonEmptyArray -const consecutivePasses = ["Pass", "Pass", "Pass"] as const -export type NonPassAuction = Auction & [...Auction, ...typeof consecutivePasses] -const passout = ["Pass", "Pass", "Pass", "Pass"] as const -export type PassAuction = typeof passout -export type CompletedAuction = NonPassAuction | PassAuction -export const eqAuction : eq.Eq = RNEA.getEq(eqBid) -export const isCompletedAuction : refinement.Refinement = (a): a is CompletedAuction => + modifier: string.Eq, +}); +export const fromBid = (bid: ContractBid): Contract => ({ + ...bid, + modifier: "Undoubled", +}); + +export type Auction = RNEA.ReadonlyNonEmptyArray; +const consecutivePasses = ["Pass", "Pass", "Pass"] as const; +export type NonPassAuction = Auction & + [...Auction, ...typeof consecutivePasses]; +const passout = ["Pass", "Pass", "Pass", "Pass"] as const; +export type PassAuction = typeof passout; +export type CompletedAuction = NonPassAuction | PassAuction; +export const eqAuction: eq.Eq = RNEA.getEq(eqBid); +export const isCompletedAuction: refinement.Refinement< + Auction, + CompletedAuction +> = (a): a is CompletedAuction => a.length >= 4 && - (eqAuction.equals(a, passout) || eqAuction.equals(a.slice(a.length - 3) as unknown as Auction, consecutivePasses)) + (eqAuction.equals(a, passout) || + eqAuction.equals( + a.slice(a.length - 3) as unknown as Auction, + consecutivePasses + )); export interface BoardWithAuction extends Board { - auction: Auction + auction: Auction; } export interface BoardWithCompletedAuction extends BoardWithAuction { - auction: CompletedAuction + auction: CompletedAuction; } diff --git a/src/model/stats.ts b/src/model/stats.ts index 53a48a5..4876424 100644 --- a/src/model/stats.ts +++ b/src/model/stats.ts @@ -1,58 +1,186 @@ -import * as aq from 'arquero'; -import { option as O, readonlyArray as RA, readonlyNonEmptyArray as RNEA, readonlyRecord as RR, readonlyTuple, semigroup } from 'fp-ts'; -import { sequenceT } from 'fp-ts/lib/Apply'; -import { flow, pipe } from 'fp-ts/lib/function'; -import * as iso from 'monocle-ts/Iso'; +import * as aq from "arquero"; +import { + option as O, + readonlyArray as RA, + readonlyNonEmptyArray as RNEA, + readonlyRecord as RR, + readonlyTuple, + semigroup as S, + ord, + number, + tuple, +} from "fp-ts"; +import { sequenceT } from "fp-ts/lib/Apply"; +import { flow, pipe } from "fp-ts/lib/function"; +import * as iso from "monocle-ts/Iso"; -import { DoubleDummyTable } from '../workers/dds.worker'; -import { flattenNestedCounts } from './analyze'; -import { Direction, directions, Strain, strains } from './bridge'; +import { DoubleDummyTable } from "../workers/dds.worker"; +import { + ContractScorePair, + OptimalBid, + eqOptimalBid, + flattenNestedCounts, + getAllScores, + transpose, +} from "./analyze"; +import { Direction, directions, Strain, strains } from "./bridge"; +import { Score, eqScore } from "./score"; +import { serializedContractBidL } from "./serialization"; -const SerializedKeyL : iso.Iso = iso.iso( +const SerializedKeyL: iso.Iso = iso.iso( ([d, s]) => d + s, - s => [s.charAt(0) as Direction, s.charAt(1) as Strain] as const -) + (s) => [s.charAt(0) as Direction, s.charAt(1) as Strain] as const +); + +const columns = pipe( + sequenceT(RNEA.Apply)(directions, strains), + RNEA.map(SerializedKeyL.get) +); + +const aggregate = + (f: (field: any) => number) => + (table: aq.internal.ColumnTable): DoubleDummyTable => + pipe( + columns, + RNEA.groupBy((c) => c), + RR.map((c) => f(c)), + (x) => table.rollup(x), + (table) => table.object() as RR.ReadonlyRecord, + RR.toReadonlyArray, + RA.map(readonlyTuple.mapFst(SerializedKeyL.reverseGet)), + RNEA.fromReadonlyArray, + O.fold( + () => ({}), + flow( + RNEA.groupBy(([[_, strain], __]) => strain), + RR.map( + flow( + RNEA.groupBy(([[direction, _], __]) => direction), + RR.map(flow(RNEA.head, ([_, agg]) => agg)) + ) + ) + ) + ) + ); -const columns = - pipe( - sequenceT(RNEA.Apply)( - directions, - strains), - RNEA.map(SerializedKeyL.get)) - -const aggregate = (f: (field: any) => number) => (table: aq.internal.ColumnTable): DoubleDummyTable => - pipe(columns, - RNEA.groupBy(c => c), - RR.map(c => f(c)), - x => table.rollup(x), - table => table.object() as RR.ReadonlyRecord, - RR.toReadonlyArray, - RA.map(readonlyTuple.mapFst(SerializedKeyL.reverseGet)), - RNEA.fromReadonlyArray, - O.fold(() => ({}), flow( - RNEA.groupBy(([[_, strain], __]) => strain), - RR.map(flow( - RNEA.groupBy(([[direction, _], __]) => direction), - RR.map(flow( - RNEA.head, - ([_, agg]) => agg))))))) - const toRow = (ddt: DoubleDummyTable) => - pipe(ddt, + pipe( + ddt, flattenNestedCounts, - RA.map(({ outerKey: direction, innerKey: strain, value: count }) => [`${direction}${strain}`, count] as const), - RR.fromFoldable(semigroup.first(), RA.Foldable)) + RA.map( + ({ outerKey: direction, innerKey: strain, value: count }) => + [`${direction}${strain}`, count] as const + ), + RR.fromFoldable(S.first(), RA.Foldable) + ); + +export type Scores = RNEA.ReadonlyNonEmptyArray< + RR.ReadonlyRecord> +>; + +export const getScores = ( + ddt: RNEA.ReadonlyNonEmptyArray +): Scores => + pipe( + ddt, + RNEA.map(flow(transpose, getAllScores("Neither"))) + // aq.from, + // ct => ct.count(), + // ct => ct.object() as RR.ReadonlyRecord + ); + +type BidScorePair = readonly [string, Score]; +export type ScoreCompare = RR.ReadonlyRecord | { tie: number }; + +const ordScore: ord.Ord = ord.reverse( + ord.fromCompare((a, b) => number.Ord.compare(a, b)) +); +const ordScores: ord.Ord = pipe( + ordScore, + ord.contramap(([_, score]) => score) +); + +const tallyScore = (results: ReadonlyArray): ScoreCompare => { + const orderedScores = pipe(results, RA.sort(ordScores), (x) => x); + if ( + !orderedScores.length || + (orderedScores.length > 1 && + eqScore.equals(orderedScores[0][1], orderedScores[1][1])) + ) { + return { tie: 1 }; + } else { + return { [orderedScores[0][0]]: 1 }; + } +}; +const sumCompare: S.Semigroup = RR.getUnionSemigroup( + number.SemigroupSum +); +const zeroCompare = (bids: RNEA.ReadonlyNonEmptyArray): ScoreCompare => + pipe( + bids, + RNEA.map((b: string) => [b, 0] as const), + RR.fromEntries + ); + +const serializeOptimalBid = (bid: OptimalBid): string => + bid === "Pass" ? "Pass" : serializedContractBidL.get(bid); + +export const compareScores = + (scores: Scores) => + (dirsAndBids: RNEA.ReadonlyNonEmptyArray<[Direction, OptimalBid]>) => + pipe( + scores, + RNEA.map((score) => + pipe( + dirsAndBids, + RNEA.map(([dir, bid]) => + pipe( + score[dir], + RA.filter(([contract]) => + bid === "Pass" + ? contract === "Pass" + : eqOptimalBid.equals(bid, contract) + ), + RNEA.fromReadonlyArray, + O.fold( + () => ({}), + RNEA.groupBy(([contract]) => serializeOptimalBid(contract)) + ), + RR.map((x) => x[0]), + RR.map(([_, score]) => score) + ) + ), + RNEA.map(RR.toReadonlyArray), + RA.chain((x) => x), + tallyScore + ) + ), + RNEA.foldMap(sumCompare)((x: ScoreCompare) => x), + (s) => + sumCompare.concat( + zeroCompare( + pipe(dirsAndBids, RNEA.map(flow(tuple.snd, serializeOptimalBid))) + ), + s + ) + ); export interface Stats { - count: number - average: DoubleDummyTable - stdev: DoubleDummyTable + count: number; + scores: Scores; + average: DoubleDummyTable; + stdev: DoubleDummyTable; } -export const getStats = flow( - RNEA.map(toRow), - aq.from, - (ct): Stats => ({ - count: ct.totalRows(), - average: pipe(ct, aggregate(aq.op.mean)), - stdev: pipe(ct, aggregate(aq.op.stdev)) - })) \ No newline at end of file + +export const getStats = (ddt: RNEA.ReadonlyNonEmptyArray) => + pipe( + ddt, + RNEA.map(toRow), + aq.from, + (ct): Stats => ({ + count: ct.totalRows(), + scores: pipe(ddt, getScores), + average: pipe(ct, aggregate(aq.op.mean)), + stdev: pipe(ct, aggregate(aq.op.stdev)), + }) + ); diff --git a/src/reducers/profile.ts b/src/reducers/profile.ts index 3e61a8b..1fb204c 100644 --- a/src/reducers/profile.ts +++ b/src/reducers/profile.ts @@ -1,191 +1,345 @@ -import { option as O, readonlyArray as RA, readonlyNonEmptyArray as RNEA, readonlyRecord as RR, readonlyTuple as RT, taskEither as TE } from 'fp-ts'; -import { observable as Ob, observableEither } from 'fp-ts-rxjs'; -import { flow, pipe } from 'fp-ts/lib/function'; -import { castDraft } from 'immer'; -import memoize from 'proxy-memoize'; -import { Epic } from 'redux-observable'; -import { concatWith, EMPTY, from, of } from 'rxjs'; -import { WritableDraft } from 'immer/dist/internal'; +import { + option as O, + readonlyArray as RA, + readonlyNonEmptyArray as RNEA, + readonlyRecord as RR, + readonlyTuple as RT, + taskEither as TE, +} from "fp-ts"; +import { observable as Ob, observableEither } from "fp-ts-rxjs"; +import { flow, pipe } from "fp-ts/lib/function"; +import { castDraft } from "immer"; +import memoize from "proxy-memoize"; +import { Epic } from "redux-observable"; +import { concatWith, EMPTY, from, of } from "rxjs"; +import { WritableDraft } from "immer/dist/internal"; -import { AnyAction, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { AnyAction, createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { RootState } from '../app/store'; -import { assertUnreachable } from '../lib'; -import { get } from '../lib/object'; -import { Analysis, AnalysisId, GenerationId, Job, JobDetailsMap, JobTypeGenerateDeals, newGenerationId, zeroAnalysis, zeroGeneration } from '../model/job'; -import { getStats } from '../model/stats'; -import { Paths } from '../model/system'; -import { ConstrainedBid } from '../model/system/core'; -import { deleteByGenerationId } from '../services/idb'; -import { completeJob, removeJob, scheduleJob, startJob } from './generator'; +import { RootState } from "../app/store"; +import { assertUnreachable } from "../lib"; +import { get } from "../lib/object"; +import { + Analysis, + AnalysisId, + GenerationId, + Job, + JobDetailsMap, + JobTypeGenerateDeals, + newGenerationId, + zeroAnalysis, + zeroGeneration, +} from "../model/job"; +import { getStats } from "../model/stats"; +import { Paths } from "../model/system"; +import { ConstrainedBid } from "../model/system/core"; +import { deleteByGenerationId } from "../services/idb"; +import { completeJob, removeJob, scheduleJob, startJob } from "./generator"; interface State { - analyses: RR.ReadonlyRecord - selectedAnalysis: O.Option + analyses: RR.ReadonlyRecord; + selectedAnalysis: O.Option; } const initialState: State = { analyses: {}, - selectedAnalysis: O.none -} + selectedAnalysis: O.none, +}; -const name = 'profile' +const name = "profile"; const slice = createSlice({ name, initialState, reducers: { - addAnalysis: (state, action: PayloadAction<{id: AnalysisId, name: string, count: number, paths: Paths}>) => { - const analysis = pipe(action.payload, ({id, name, count, paths}) => zeroAnalysis(id, name, paths), castDraft) - state.analyses[analysis.id] = analysis + addAnalysis: ( + state, + action: PayloadAction<{ + id: AnalysisId; + name: string; + count: number; + paths: Paths; + }> + ) => { + const analysis = pipe( + action.payload, + ({ id, name, count, paths }) => zeroAnalysis(id, name, paths), + castDraft + ); + state.analyses[analysis.id] = analysis; }, - deleteAnalysis: (state, action: PayloadAction) => { }, + deleteAnalysis: (state, action: PayloadAction) => {}, removeAnalysis: (state, action: PayloadAction) => { - delete state.analyses[action.payload] + delete state.analyses[action.payload]; }, selectAnalysis: (state, action: PayloadAction) => { - state.selectedAnalysis = pipe(RR.has(action.payload, state.analyses) ? O.some(action.payload) : O.none) + state.selectedAnalysis = pipe( + RR.has(action.payload, state.analyses) ? O.some(action.payload) : O.none + ); }, setAnalysisName: { reducer: (state, action: PayloadAction) => { - state.analyses[action.meta].name = action.payload + state.analyses[action.meta].name = action.payload; }, - prepare: (id: AnalysisId, name: string) => ({ payload: name, meta: id }) + prepare: (id: AnalysisId, name: string) => ({ payload: name, meta: id }), }, - addJobToAnalysis: (state: WritableDraft, action: PayloadAction>) => { - pipe(state.analyses, + addJobToAnalysis: ( + state: WritableDraft, + action: PayloadAction> + ) => { + pipe( + state.analyses, RR.lookup(action.payload.analysisId), - O.map(analysis => { + O.map((analysis) => { switch (action.payload.type) { case "GenerateDeals": - const genJob = action.payload as Job<"GenerateDeals"> - return pipe(genJob.details.progress, - O.map(p => analysis.generations.push(pipe(zeroGeneration(genJob.details.context.generationId, p.value), castDraft)))) + const genJob = action.payload as Job<"GenerateDeals">; + return pipe( + genJob.details.progress, + O.map((p) => + analysis.generations.push( + pipe( + zeroGeneration( + genJob.details.context.generationId, + p.value + ), + castDraft + ) + ) + ) + ); case "Satisfies": - const satJob = action.payload as Job<"Satisfies"> - return pipe(O.Do, - O.apS('progress', satJob.details.progress), - O.apS('generation', pipe(analysis.generations, RA.findFirst(g => g.id === satJob.details.context.generationId))), - O.map(o => o.generation.satisfies = O.some(o.progress.value))) + const satJob = action.payload as Job<"Satisfies">; + return pipe( + O.Do, + O.apS("progress", satJob.details.progress), + O.apS( + "generation", + pipe( + analysis.generations, + RA.findFirst( + (g) => g.id === satJob.details.context.generationId + ) + ) + ), + O.map( + (o) => (o.generation.satisfies = O.some(o.progress.value)) + ) + ); case "Solve": - const solveJob = action.payload as Job<"Solve"> - return pipe(O.Do, - O.apS('progress', solveJob.details.progress), - O.apS('generation', pipe(analysis.generations, RA.findFirst(g => g.id === solveJob.details.context.generationId))), - O.map(o => pipe( + const solveJob = action.payload as Job<"Solve">; + return pipe( + O.Do, + O.apS("progress", solveJob.details.progress), + O.apS( + "generation", + pipe( + analysis.generations, + RA.findFirst( + (g) => g.id === solveJob.details.context.generationId + ) + ) + ), + O.map((o) => + pipe( o.progress.value, RR.toReadonlyArray, RNEA.fromReadonlyArray, - O.map(flow( - RNEA.map(flow(RT.snd, get('results'))), + O.map( + flow( + RNEA.map(flow(RT.snd, get("results"))), getStats, - stats => { - if (RR.has(solveJob.details.context.bidPath, o.generation.solutionStats)) { - throw new Error("Combining stat result sets is not implemented") + (stats) => { + if ( + RR.has( + solveJob.details.context.bidPath, + o.generation.solutionStats + ) + ) { + throw new Error( + "Combining stat result sets is not implemented" + ); } - o.generation.solutionStats[solveJob.details.context.bidPath] = stats - }))))) + o.generation.solutionStats[ + solveJob.details.context.bidPath + ] = castDraft(stats); + } + ) + ) + ) + ) + ); default: - return assertUnreachable(action.payload.type) + return assertUnreachable(action.payload.type); } - }))} + }) + ); + }, }, -}) +}); -export const { addAnalysis, deleteAnalysis, selectAnalysis, setAnalysisName, addJobToAnalysis } = slice.actions -export default slice.reducer +export const { + addAnalysis, + deleteAnalysis, + selectAnalysis, + setAnalysisName, + addJobToAnalysis, +} = slice.actions; +export default slice.reducer; export const epics: ReadonlyArray> = [ (action$, state$) => action$.pipe( Ob.filter(addAnalysis.match), - Ob.chain(flow(a => a.payload, ({ id, count }) => - pipe(state$.value.profile.analyses, - RR.keys, - RA.filter((id2) => id2 !== id), - RA.map(deleteAnalysis), - analyses => from([ - ...analyses, - selectAnalysis(id), - scheduleJob({ - analysisId: id, - type: "GenerateDeals", - context: { generationId: newGenerationId() }, - parameter: count, - estimatedUnitsInitial: count, - })]))))), + Ob.chain( + flow( + (a) => a.payload, + ({ id, count }) => + pipe( + state$.value.profile.analyses, + RR.keys, + RA.filter((id2) => id2 !== id), + RA.map(deleteAnalysis), + (analyses) => + from([ + ...analyses, + selectAnalysis(id), + scheduleJob({ + analysisId: id, + type: "GenerateDeals", + context: { generationId: newGenerationId() }, + parameter: count, + estimatedUnitsInitial: count, + }), + ]) + ) + ) + ) + ), (action$, state$) => action$.pipe( Ob.filter(addJobToAnalysis.match), - Ob.filterMap(a => - pipe(O.Do, + Ob.filterMap((a) => + pipe( + O.Do, O.bind("job", () => a.payload.type === "GenerateDeals" - ? O.some(a.payload as Job & { details: JobTypeGenerateDeals }) - : O.none), + ? O.some(a.payload as Job & { details: JobTypeGenerateDeals }) + : O.none + ), O.bind("analysis", ({ job }) => selectAnalysisById({ state: state$.value.profile, analysisId: job.analysisId, - })))), + }) + ) + ) + ), Ob.chain(({ job, analysis }) => - of(scheduleJob({ - analysisId: job.analysisId, - type: "Satisfies", - parameter: analysis.paths, - context: job.details.context, - estimatedUnitsInitial: analysis.paths.length * job.details.parameter, - })))), + of( + scheduleJob({ + analysisId: job.analysisId, + type: "Satisfies", + parameter: analysis.paths, + context: job.details.context, + estimatedUnitsInitial: + analysis.paths.length * job.details.parameter, + }) + ) + ) + ), (action$, state$) => action$.pipe( Ob.filter(completeJob.match), - Ob.filter(a => O.isNone(a.error)), - Ob.chain(flow(a => a.meta, jobId => - pipe(state$.value.generator.completed, - RA.findFirst(j => j.id === jobId), + Ob.filter((a) => O.isNone(a.error)), + Ob.chain( + flow( + (a) => a.meta, + (jobId) => + pipe( + state$.value.generator.completed, + RA.findFirst((j) => j.id === jobId), O.fold( () => EMPTY, - j => from([addJobToAnalysis(j), removeJob(jobId)])))))), + (j) => from([addJobToAnalysis(j), removeJob(jobId)]) + ) + ) + ) + ) + ), (action$, state$) => state$.pipe( - Ob.map(s => pipe(s.generator.jobs, RA.filter((j) => O.isNone(j.startDate)))), + Ob.map((s) => + pipe( + s.generator.jobs, + RA.filter((j) => O.isNone(j.startDate)) + ) + ), Ob.filterMap(RNEA.fromReadonlyArray), - Ob.chain(flow( - RA.map(j => startJob({ jobId: j.id, type: j.type })), - from))), + Ob.chain( + flow( + RA.map((j) => startJob({ jobId: j.id, type: j.type })), + from + ) + ) + ), (action$, state$) => action$.pipe( Ob.filter(deleteAnalysis.match), - Ob.map(a => a.payload), - Ob.chain(analysisId => - pipe(state$.value.profile.analyses, RR.lookup(analysisId), - O.fold(() => EMPTY, a => - pipe(a.generations, RA.map(g => g.id), TE.traverseArray(deleteByGenerationId), Ob.fromTask)), - observableEither.fold(() => EMPTY, x => EMPTY), - concatWith(of(slice.actions.removeAnalysis(analysisId)))))) -] + Ob.map((a) => a.payload), + Ob.chain((analysisId) => + pipe( + state$.value.profile.analyses, + RR.lookup(analysisId), + O.fold( + () => EMPTY, + (a) => + pipe( + a.generations, + RA.map((g) => g.id), + TE.traverseArray(deleteByGenerationId), + Ob.fromTask + ) + ), + observableEither.fold( + () => EMPTY, + (x) => EMPTY + ), + concatWith(of(slice.actions.removeAnalysis(analysisId))) + ) + ) + ), +]; export const selectAllAnalyses = memoize((state: State) => - pipe(state.analyses, - RR.toReadonlyArray, - RA.map(RT.snd))) + pipe(state.analyses, RR.toReadonlyArray, RA.map(RT.snd)) +); export const selectSelectedAnalysis = memoize((state: State) => - pipe(state.selectedAnalysis, - O.chain(id => RR.lookup(id, state.analyses)))) + pipe( + state.selectedAnalysis, + O.chain((id) => RR.lookup(id, state.analyses)) + ) +); interface AnalysisIndex { - state: State - analysisId: AnalysisId + state: State; + analysisId: AnalysisId; } export const selectAnalysisById = memoize((idx: AnalysisIndex) => - pipe(idx.state.analyses, - RR.lookup(idx.analysisId))) + pipe(idx.state.analyses, RR.lookup(idx.analysisId)) +); interface GenerationIndex extends AnalysisIndex { - generationId: GenerationId + generationId: GenerationId; } export const selectGenerationByAnalysis = memoize((idx: GenerationIndex) => - pipe(selectAnalysisById(idx), - O.chain(flow( - get("generations"), - RA.findFirst(g => g.id === idx.generationId))))) \ No newline at end of file + pipe( + selectAnalysisById(idx), + O.chain( + flow( + get("generations"), + RA.findFirst((g) => g.id === idx.generationId) + ) + ) + ) +);